loopengine 0.1.67 → 0.1.69
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/README.md +16 -2
- package/dist/core/agent-config.d.ts +5 -0
- package/dist/core/approval-links.d.ts +16 -0
- package/dist/core/approval-links.js +76 -0
- package/dist/core/durable-approvals.d.ts +6 -0
- package/dist/core/durable-approvals.js +20 -0
- package/dist/core/http-notifier.js +6 -6
- package/dist/core/http-notify-triggers/lark.d.ts +9 -0
- package/dist/core/http-notify-triggers/lark.js +39 -19
- package/dist/core/http-notify-triggers/slack.d.ts +12 -0
- package/dist/core/http-notify-triggers/slack.js +31 -10
- package/dist/core/http-notify-triggers/webhook.d.ts +8 -0
- package/dist/core/http-notify-triggers/webhook.js +23 -8
- package/dist/core/notifier-file.d.ts +8 -0
- package/dist/core/notifier-file.js +27 -6
- package/dist/core/slack-interactions.d.ts +59 -0
- package/dist/core/slack-interactions.js +168 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/web/agents-config-page.js +134 -18
- package/dist/web/decide-page.d.ts +16 -0
- package/dist/web/decide-page.js +138 -0
- package/dist/web/notifier-admin.d.ts +20 -0
- package/dist/web/notifier-admin.js +34 -1
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -171,11 +171,25 @@ export const config: AgentConfig = {
|
|
|
171
171
|
}
|
|
172
172
|
```
|
|
173
173
|
|
|
174
|
-
Or skip the code: in the console,
|
|
174
|
+
Or skip the code: in the console, the Permissions tab's **Where approval
|
|
175
|
+
requests and questions are sent** section sets a
|
|
175
176
|
webhook, Slack or Lark target for one agent (saved to
|
|
176
177
|
`agents/<name>/notifier.yml`, with its secret in the agent's own `.env`),
|
|
177
178
|
sends a test, shows recent deliveries, and lists every decision still
|
|
178
|
-
waiting with Approve/Deny buttons.
|
|
179
|
+
waiting with Approve/Deny buttons. The tab warns you when an agent has
|
|
180
|
+
`ask` rules but nobody would be told.
|
|
181
|
+
|
|
182
|
+
Every notification links to a **decision page**: a signed, single-use
|
|
183
|
+
link (`/decide/<token>`, valid 7 days) where whoever got the message can
|
|
184
|
+
approve, deny, edit the arguments or answer a question, with no login and
|
|
185
|
+
no app of your own. So the quickest setup is Slack: create the Slack app
|
|
186
|
+
from the manifest the console shows, paste its bot token, pick a channel
|
|
187
|
+
from the list, save; each approval arrives with an "Open to approve or
|
|
188
|
+
deny" link. Add the app's signing secret too and the message also gets
|
|
189
|
+
Approve/Deny buttons, handled by LoopEngine itself at
|
|
190
|
+
`/hooks/slack/<agent>`, with no handler to deploy. Lark cards carry the
|
|
191
|
+
same link. Links and buttons need the server to be reachable from where
|
|
192
|
+
people click (a public HTTPS address or a tunnel).
|
|
179
193
|
|
|
180
194
|
For a third-party app calling an agent over plain HTTP:
|
|
181
195
|
|
|
@@ -185,6 +185,7 @@ export type HttpNotifierConfig = {
|
|
|
185
185
|
config: {
|
|
186
186
|
webhookUrl: string;
|
|
187
187
|
webhookSecret: string;
|
|
188
|
+
publicUrl?: string;
|
|
188
189
|
};
|
|
189
190
|
events: HttpNotifierEvent[];
|
|
190
191
|
} | {
|
|
@@ -192,6 +193,8 @@ export type HttpNotifierConfig = {
|
|
|
192
193
|
config: {
|
|
193
194
|
botToken: string;
|
|
194
195
|
channelId: string;
|
|
196
|
+
publicUrl?: string;
|
|
197
|
+
buttons?: boolean;
|
|
195
198
|
};
|
|
196
199
|
events: HttpNotifierEvent[];
|
|
197
200
|
} | {
|
|
@@ -200,6 +203,8 @@ export type HttpNotifierConfig = {
|
|
|
200
203
|
appId: string;
|
|
201
204
|
appSecret: string;
|
|
202
205
|
chatId: string;
|
|
206
|
+
publicUrl?: string;
|
|
207
|
+
buttons?: boolean;
|
|
203
208
|
};
|
|
204
209
|
events: HttpNotifierEvent[];
|
|
205
210
|
} | {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type DecisionKind = 'approval' | 'question';
|
|
2
|
+
/** How long a link works: 7 days, or LOOPENGINE_LINK_TTL_HOURS. */
|
|
3
|
+
export declare function decisionLinkTtlMs(): number;
|
|
4
|
+
export declare function createDecisionToken(pendingId: string, kind: DecisionKind, nowMs?: number): string;
|
|
5
|
+
/** The pendingId and kind a token names, or null for a malformed,
|
|
6
|
+
* tampered or expired one. */
|
|
7
|
+
export declare function verifyDecisionToken(token: string, nowMs?: number): {
|
|
8
|
+
pendingId: string;
|
|
9
|
+
kind: DecisionKind;
|
|
10
|
+
expiresAt: number;
|
|
11
|
+
} | null;
|
|
12
|
+
export declare function decidePath(pendingId: string, kind: DecisionKind): string;
|
|
13
|
+
/** Builds full decision links against `baseUrl` — undefined when there's
|
|
14
|
+
* no base URL to build them against. */
|
|
15
|
+
export type DecideLinkMaker = (pendingId: string, kind: DecisionKind) => string | undefined;
|
|
16
|
+
export declare function decideLinkMaker(baseUrl: string | undefined): DecideLinkMaker;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Decision links: a signed, expiring URL for one waiting approval or
|
|
2
|
+
// question (/decide/<token>) that opens a small page where whoever has
|
|
3
|
+
// the link can approve, deny or answer — no admin login, no app of your
|
|
4
|
+
// own, no Slack interactivity setup. Every notifier that knows where this
|
|
5
|
+
// server is reachable puts one in its message.
|
|
6
|
+
//
|
|
7
|
+
// The token only names the pendingId, its kind and an expiry, signed
|
|
8
|
+
// with HMAC-SHA256. It's single-use by construction: once the decision
|
|
9
|
+
// is made the pending item no longer exists, so the same link just shows
|
|
10
|
+
// "already decided."
|
|
11
|
+
//
|
|
12
|
+
// The signing secret is LOOPENGINE_LINK_SECRET when set (required for a
|
|
13
|
+
// deployment with more than one server, which must share it), otherwise
|
|
14
|
+
// one generated on first use and kept in .checkpoints/link-secret next to
|
|
15
|
+
// the paused turns it unlocks, so links keep working across restarts.
|
|
16
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
/** How long a link works: 7 days, or LOOPENGINE_LINK_TTL_HOURS. */
|
|
20
|
+
export function decisionLinkTtlMs() {
|
|
21
|
+
const hours = Number(process.env.LOOPENGINE_LINK_TTL_HOURS);
|
|
22
|
+
return (Number.isFinite(hours) && hours > 0 ? hours : 24 * 7) * 3_600_000;
|
|
23
|
+
}
|
|
24
|
+
let generatedSecret;
|
|
25
|
+
function linkSecret() {
|
|
26
|
+
if (process.env.LOOPENGINE_LINK_SECRET)
|
|
27
|
+
return process.env.LOOPENGINE_LINK_SECRET;
|
|
28
|
+
if (generatedSecret)
|
|
29
|
+
return generatedSecret;
|
|
30
|
+
const dir = join(process.cwd(), '.checkpoints');
|
|
31
|
+
const path = join(dir, 'link-secret');
|
|
32
|
+
if (existsSync(path)) {
|
|
33
|
+
generatedSecret = readFileSync(path, 'utf8').trim();
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
generatedSecret = randomBytes(32).toString('hex');
|
|
37
|
+
mkdirSync(dir, { recursive: true });
|
|
38
|
+
writeFileSync(path, generatedSecret + '\n', { mode: 0o600 });
|
|
39
|
+
}
|
|
40
|
+
return generatedSecret;
|
|
41
|
+
}
|
|
42
|
+
function sign(body) {
|
|
43
|
+
return createHmac('sha256', linkSecret()).update(body).digest('base64url');
|
|
44
|
+
}
|
|
45
|
+
export function createDecisionToken(pendingId, kind, nowMs = Date.now()) {
|
|
46
|
+
const body = Buffer.from(JSON.stringify({ p: pendingId, k: kind, exp: nowMs + decisionLinkTtlMs() })).toString('base64url');
|
|
47
|
+
return `${body}.${sign(body)}`;
|
|
48
|
+
}
|
|
49
|
+
/** The pendingId and kind a token names, or null for a malformed,
|
|
50
|
+
* tampered or expired one. */
|
|
51
|
+
export function verifyDecisionToken(token, nowMs = Date.now()) {
|
|
52
|
+
const [body, signature] = token.split('.');
|
|
53
|
+
if (!body || !signature)
|
|
54
|
+
return null;
|
|
55
|
+
const expected = Buffer.from(sign(body));
|
|
56
|
+
const actual = Buffer.from(signature);
|
|
57
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual))
|
|
58
|
+
return null;
|
|
59
|
+
try {
|
|
60
|
+
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
|
61
|
+
if (typeof payload.p !== 'string' || (payload.k !== 'approval' && payload.k !== 'question') || typeof payload.exp !== 'number')
|
|
62
|
+
return null;
|
|
63
|
+
if (nowMs > payload.exp)
|
|
64
|
+
return null;
|
|
65
|
+
return { pendingId: payload.p, kind: payload.k, expiresAt: payload.exp };
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function decidePath(pendingId, kind) {
|
|
72
|
+
return `/decide/${createDecisionToken(pendingId, kind)}`;
|
|
73
|
+
}
|
|
74
|
+
export function decideLinkMaker(baseUrl) {
|
|
75
|
+
return (pendingId, kind) => (baseUrl ? baseUrl.replace(/\/+$/, '') + decidePath(pendingId, kind) : undefined);
|
|
76
|
+
}
|
|
@@ -65,6 +65,10 @@ export interface CheckpointStore {
|
|
|
65
65
|
* first — backs GET /agents/:name/pending. Reads without locking: a
|
|
66
66
|
* snapshot for display, not something to act on without withCheckpoint. */
|
|
67
67
|
listOpen(): Promise<TurnCheckpoint[]>;
|
|
68
|
+
/** The open checkpoint `pendingId` belongs to, read without locking —
|
|
69
|
+
* for showing a decision page, not for acting on it (use
|
|
70
|
+
* withCheckpoint for that). undefined once it's decided or unknown. */
|
|
71
|
+
get(pendingId: string): Promise<TurnCheckpoint | undefined>;
|
|
68
72
|
close(): Promise<void>;
|
|
69
73
|
}
|
|
70
74
|
/** One JSON file per checkpoint under `<dir>/checkpoints/`, plus one
|
|
@@ -82,6 +86,7 @@ export declare class FileCheckpointStore implements CheckpointStore {
|
|
|
82
86
|
checkpoint: TurnCheckpoint | undefined;
|
|
83
87
|
result: T;
|
|
84
88
|
}>): Promise<T>;
|
|
89
|
+
get(pendingId: string): Promise<TurnCheckpoint | undefined>;
|
|
85
90
|
listOpen(): Promise<TurnCheckpoint[]>;
|
|
86
91
|
close(): Promise<void>;
|
|
87
92
|
}
|
|
@@ -106,6 +111,7 @@ export declare class RedisCheckpointStore implements CheckpointStore {
|
|
|
106
111
|
checkpoint: TurnCheckpoint | undefined;
|
|
107
112
|
result: T;
|
|
108
113
|
}>): Promise<T>;
|
|
114
|
+
get(pendingId: string): Promise<TurnCheckpoint | undefined>;
|
|
109
115
|
listOpen(): Promise<TurnCheckpoint[]>;
|
|
110
116
|
close(): Promise<void>;
|
|
111
117
|
}
|
|
@@ -72,6 +72,16 @@ export class FileCheckpointStore {
|
|
|
72
72
|
return result;
|
|
73
73
|
});
|
|
74
74
|
}
|
|
75
|
+
async get(pendingId) {
|
|
76
|
+
const indexPath = this.indexPath(pendingId);
|
|
77
|
+
if (!existsSync(indexPath))
|
|
78
|
+
return undefined;
|
|
79
|
+
const checkpointPath = this.checkpointPath(readFileSync(indexPath, 'utf8'));
|
|
80
|
+
if (!existsSync(checkpointPath))
|
|
81
|
+
return undefined;
|
|
82
|
+
const checkpoint = JSON.parse(readFileSync(checkpointPath, 'utf8'));
|
|
83
|
+
return !checkpoint.closed && checkpoint.outstanding[pendingId] ? checkpoint : undefined;
|
|
84
|
+
}
|
|
75
85
|
async listOpen() {
|
|
76
86
|
const open = [];
|
|
77
87
|
for (const name of readdirSync(this.checkpointsDir)) {
|
|
@@ -164,6 +174,16 @@ export class RedisCheckpointStore {
|
|
|
164
174
|
return result;
|
|
165
175
|
});
|
|
166
176
|
}
|
|
177
|
+
async get(pendingId) {
|
|
178
|
+
const checkpointId = await this.redis.get(this.indexKey(pendingId));
|
|
179
|
+
if (!checkpointId)
|
|
180
|
+
return undefined;
|
|
181
|
+
const raw = await this.redis.get(this.checkpointKey(checkpointId));
|
|
182
|
+
if (!raw)
|
|
183
|
+
return undefined;
|
|
184
|
+
const checkpoint = JSON.parse(raw);
|
|
185
|
+
return !checkpoint.closed && checkpoint.outstanding[pendingId] ? checkpoint : undefined;
|
|
186
|
+
}
|
|
167
187
|
async listOpen() {
|
|
168
188
|
const open = [];
|
|
169
189
|
let cursor = '0';
|
|
@@ -84,11 +84,11 @@ function buildResolved(httpNotifier) {
|
|
|
84
84
|
let resolved;
|
|
85
85
|
switch (httpNotifier.channel) {
|
|
86
86
|
case 'webhook': {
|
|
87
|
-
const { webhookUrl, webhookSecret } = httpNotifier.config;
|
|
87
|
+
const { webhookUrl, webhookSecret, publicUrl } = httpNotifier.config;
|
|
88
88
|
// One shared instance for both concerns, same "it's channel-
|
|
89
89
|
// specific, not concern-specific" reasoning every other channel's
|
|
90
90
|
// own file already has — see WebhookNotifier's own doc comment.
|
|
91
|
-
const webhook = new WebhookNotifier({ webhookUrl, signingSecret: webhookSecret });
|
|
91
|
+
const webhook = new WebhookNotifier({ webhookUrl, signingSecret: webhookSecret, publicUrl });
|
|
92
92
|
resolved = {
|
|
93
93
|
approver: events.has('approval') ? webhook : undefined,
|
|
94
94
|
questionHandler: events.has('question') ? webhook : undefined,
|
|
@@ -98,13 +98,13 @@ function buildResolved(httpNotifier) {
|
|
|
98
98
|
break;
|
|
99
99
|
}
|
|
100
100
|
case 'slack': {
|
|
101
|
-
const { botToken, channelId } = httpNotifier.config;
|
|
101
|
+
const { botToken, channelId, publicUrl, buttons } = httpNotifier.config;
|
|
102
102
|
// One shared instance for every concern this httpNotifier actually
|
|
103
103
|
// covers — same "it's Slack-specific, not approval/question/
|
|
104
104
|
// lifecycle-specific" reasoning SlackNotifier's own doc
|
|
105
105
|
// comment works out; cheap to construct (holds two strings, does no
|
|
106
106
|
// I/O itself) so there's no reason to build one per event kind.
|
|
107
|
-
const slack = new SlackNotifier({ botToken, channelId });
|
|
107
|
+
const slack = new SlackNotifier({ botToken, channelId, publicUrl, buttons });
|
|
108
108
|
resolved = {
|
|
109
109
|
approver: events.has('approval') ? slack : undefined,
|
|
110
110
|
questionHandler: events.has('question') ? slack : undefined,
|
|
@@ -114,8 +114,8 @@ function buildResolved(httpNotifier) {
|
|
|
114
114
|
break;
|
|
115
115
|
}
|
|
116
116
|
case 'lark': {
|
|
117
|
-
const { appId, appSecret, chatId } = httpNotifier.config;
|
|
118
|
-
const lark = new LarkNotifier({ appId, appSecret, chatId });
|
|
117
|
+
const { appId, appSecret, chatId, publicUrl, buttons } = httpNotifier.config;
|
|
118
|
+
const lark = new LarkNotifier({ appId, appSecret, chatId, publicUrl, buttons });
|
|
119
119
|
resolved = {
|
|
120
120
|
approver: events.has('approval') ? lark : undefined,
|
|
121
121
|
questionHandler: events.has('question') ? lark : undefined,
|
|
@@ -9,11 +9,20 @@ export declare class LarkNotifier implements DurableApprover, DurableQuestionHan
|
|
|
9
9
|
private readonly appId;
|
|
10
10
|
private readonly appSecret;
|
|
11
11
|
private readonly chatId;
|
|
12
|
+
private readonly publicUrl?;
|
|
13
|
+
private readonly buttons;
|
|
14
|
+
/** `publicUrl` / `buttons`: same meaning as SlackNotifier's — a decision
|
|
15
|
+
* link in each card when the server's address is known, and whether to
|
|
16
|
+
* add card buttons, which only work with a card-callback handler
|
|
17
|
+
* deployed (examples/notifier-handler/lark.ts). */
|
|
12
18
|
constructor(options: {
|
|
13
19
|
appId: string;
|
|
14
20
|
appSecret: string;
|
|
15
21
|
chatId: string;
|
|
22
|
+
publicUrl?: string;
|
|
23
|
+
buttons?: boolean;
|
|
16
24
|
});
|
|
25
|
+
private decideLine;
|
|
17
26
|
requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
|
|
18
27
|
pendingId: string;
|
|
19
28
|
};
|
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
// sending and receiving ever run in different processes), deliberately
|
|
39
39
|
// not the approach used here.
|
|
40
40
|
import { randomUUID } from 'node:crypto';
|
|
41
|
+
import { decidePath } from '../approval-links.js';
|
|
42
|
+
import { publicBaseUrl } from './webhook.js';
|
|
41
43
|
/** Fixed id for the question form card's own input field — used both
|
|
42
44
|
* when *building* the card (notifyPendingQuestion below) and when
|
|
43
45
|
* *reading* the human's answer back out of the callback payload (the
|
|
@@ -62,10 +64,22 @@ export class LarkNotifier {
|
|
|
62
64
|
appId;
|
|
63
65
|
appSecret;
|
|
64
66
|
chatId;
|
|
67
|
+
publicUrl;
|
|
68
|
+
buttons;
|
|
69
|
+
/** `publicUrl` / `buttons`: same meaning as SlackNotifier's — a decision
|
|
70
|
+
* link in each card when the server's address is known, and whether to
|
|
71
|
+
* add card buttons, which only work with a card-callback handler
|
|
72
|
+
* deployed (examples/notifier-handler/lark.ts). */
|
|
65
73
|
constructor(options) {
|
|
66
74
|
this.appId = options.appId;
|
|
67
75
|
this.appSecret = options.appSecret;
|
|
68
76
|
this.chatId = options.chatId;
|
|
77
|
+
this.publicUrl = options.publicUrl;
|
|
78
|
+
this.buttons = options.buttons ?? true;
|
|
79
|
+
}
|
|
80
|
+
decideLine(pendingId, kind) {
|
|
81
|
+
const base = publicBaseUrl(this.publicUrl);
|
|
82
|
+
return base ? `\n[${kind === 'approval' ? 'Open to approve or deny' : 'Open to answer'}](${base}${decidePath(pendingId, kind)})` : '';
|
|
69
83
|
}
|
|
70
84
|
requestDurableApproval(tool, args, scope, reason) {
|
|
71
85
|
const pendingId = randomUUID();
|
|
@@ -80,16 +94,18 @@ export class LarkNotifier {
|
|
|
80
94
|
tag: 'div',
|
|
81
95
|
text: {
|
|
82
96
|
tag: 'lark_md',
|
|
83
|
-
content: `**tool:** ${tool}\n**args:** \`${JSON.stringify(args)}\`\n**scope:** ${scope.tenant}/${scope.environment}/${scope.agent}\n**reason:** ${reason}`,
|
|
97
|
+
content: `**tool:** ${tool}\n**args:** \`${JSON.stringify(args)}\`\n**scope:** ${scope.tenant}/${scope.environment}/${scope.agent}\n**reason:** ${reason}${this.decideLine(pendingId, 'approval')}`,
|
|
84
98
|
},
|
|
85
99
|
},
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
100
|
+
...(this.buttons
|
|
101
|
+
? [{
|
|
102
|
+
tag: 'action',
|
|
103
|
+
actions: [
|
|
104
|
+
{ tag: 'button', text: { tag: 'plain_text', content: 'Approve' }, type: 'primary', value: { pendingId, decision: 'approve' } },
|
|
105
|
+
{ tag: 'button', text: { tag: 'plain_text', content: 'Deny' }, type: 'danger', value: { pendingId, decision: 'deny' } },
|
|
106
|
+
],
|
|
107
|
+
}]
|
|
108
|
+
: []),
|
|
93
109
|
],
|
|
94
110
|
};
|
|
95
111
|
await this.sendCard(token, card, pendingId);
|
|
@@ -114,19 +130,23 @@ export class LarkNotifier {
|
|
|
114
130
|
config: { wide_screen_mode: true },
|
|
115
131
|
header: { title: { tag: 'plain_text', content: `Question from ${agent}` } },
|
|
116
132
|
elements: [
|
|
117
|
-
{ tag: 'div', text: { tag: 'lark_md', content: question + (sessionId ? `\n_(session ${sessionId})_` : '') } },
|
|
118
|
-
...(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
name: 'answer_form',
|
|
122
|
-
elements: [
|
|
123
|
-
{ tag: 'input', name: LARK_ANSWER_FIELD_TAG, placeholder: { tag: 'plain_text', content: 'Or type your own answer…' } },
|
|
133
|
+
{ tag: 'div', text: { tag: 'lark_md', content: question + (sessionId ? `\n_(session ${sessionId})_` : '') + this.decideLine(pendingId, 'question') } },
|
|
134
|
+
...(this.buttons
|
|
135
|
+
? [
|
|
136
|
+
...(optionButtons.length ? [{ tag: 'action', actions: optionButtons }] : []),
|
|
124
137
|
{
|
|
125
|
-
tag: '
|
|
126
|
-
|
|
138
|
+
tag: 'form',
|
|
139
|
+
name: 'answer_form',
|
|
140
|
+
elements: [
|
|
141
|
+
{ tag: 'input', name: LARK_ANSWER_FIELD_TAG, placeholder: { tag: 'plain_text', content: 'Or type your own answer…' } },
|
|
142
|
+
{
|
|
143
|
+
tag: 'action',
|
|
144
|
+
actions: [{ tag: 'button', text: { tag: 'plain_text', content: 'Send' }, type: 'primary', action_type: 'form_submit', value: { pendingId } }],
|
|
145
|
+
},
|
|
146
|
+
],
|
|
127
147
|
},
|
|
128
|
-
]
|
|
129
|
-
|
|
148
|
+
]
|
|
149
|
+
: []),
|
|
130
150
|
],
|
|
131
151
|
};
|
|
132
152
|
await this.sendCard(token, card, pendingId);
|
|
@@ -3,10 +3,22 @@ import type { DurableQuestionHandler } from '../agent-config.js';
|
|
|
3
3
|
export declare class SlackNotifier implements DurableApprover, DurableQuestionHandler {
|
|
4
4
|
private readonly botToken;
|
|
5
5
|
private readonly channelId;
|
|
6
|
+
private readonly publicUrl?;
|
|
7
|
+
private readonly buttons;
|
|
8
|
+
/** `publicUrl`: where this server is reachable (LOOPENGINE_PUBLIC_URL
|
|
9
|
+
* wins when set) — when known, each message links to a decision page
|
|
10
|
+
* (core/approval-links.ts) anyone in the channel can open. `buttons`:
|
|
11
|
+
* whether to add interactive Approve/Deny buttons, which only work with
|
|
12
|
+
* a Slack interactivity endpoint deployed (examples/notifier-handler/
|
|
13
|
+
* slack.ts); default true, false for a console-configured notifier,
|
|
14
|
+
* which relies on the link instead. */
|
|
6
15
|
constructor(options: {
|
|
7
16
|
botToken: string;
|
|
8
17
|
channelId: string;
|
|
18
|
+
publicUrl?: string;
|
|
19
|
+
buttons?: boolean;
|
|
9
20
|
});
|
|
21
|
+
private decideLine;
|
|
10
22
|
requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
|
|
11
23
|
pendingId: string;
|
|
12
24
|
};
|
|
@@ -24,12 +24,29 @@
|
|
|
24
24
|
// it's Slack-specific (the bot token, the channel, the chat.postMessage
|
|
25
25
|
// call shape).
|
|
26
26
|
import { randomUUID } from 'node:crypto';
|
|
27
|
+
import { decidePath } from '../approval-links.js';
|
|
28
|
+
import { publicBaseUrl } from './webhook.js';
|
|
27
29
|
export class SlackNotifier {
|
|
28
30
|
botToken;
|
|
29
31
|
channelId;
|
|
32
|
+
publicUrl;
|
|
33
|
+
buttons;
|
|
34
|
+
/** `publicUrl`: where this server is reachable (LOOPENGINE_PUBLIC_URL
|
|
35
|
+
* wins when set) — when known, each message links to a decision page
|
|
36
|
+
* (core/approval-links.ts) anyone in the channel can open. `buttons`:
|
|
37
|
+
* whether to add interactive Approve/Deny buttons, which only work with
|
|
38
|
+
* a Slack interactivity endpoint deployed (examples/notifier-handler/
|
|
39
|
+
* slack.ts); default true, false for a console-configured notifier,
|
|
40
|
+
* which relies on the link instead. */
|
|
30
41
|
constructor(options) {
|
|
31
42
|
this.botToken = options.botToken;
|
|
32
43
|
this.channelId = options.channelId;
|
|
44
|
+
this.publicUrl = options.publicUrl;
|
|
45
|
+
this.buttons = options.buttons ?? true;
|
|
46
|
+
}
|
|
47
|
+
decideLine(pendingId, kind) {
|
|
48
|
+
const base = publicBaseUrl(this.publicUrl);
|
|
49
|
+
return base ? `\n<${base}${decidePath(pendingId, kind)}|${kind === 'approval' ? 'Open to approve or deny' : 'Open to answer'}>` : '';
|
|
33
50
|
}
|
|
34
51
|
requestDurableApproval(tool, args, scope, reason) {
|
|
35
52
|
const pendingId = randomUUID();
|
|
@@ -38,16 +55,18 @@ export class SlackNotifier {
|
|
|
38
55
|
type: 'section',
|
|
39
56
|
text: {
|
|
40
57
|
type: 'mrkdwn',
|
|
41
|
-
text: `*Approval requested*\n*tool:* ${tool}\n*args:* \`${JSON.stringify(args)}\`\n*scope:* ${scope.tenant}/${scope.environment}/${scope.agent}\n*reason:* ${reason}`,
|
|
58
|
+
text: `*Approval requested*\n*tool:* ${tool}\n*args:* \`${JSON.stringify(args)}\`\n*scope:* ${scope.tenant}/${scope.environment}/${scope.agent}\n*reason:* ${reason}${this.decideLine(pendingId, 'approval')}`,
|
|
42
59
|
},
|
|
43
60
|
},
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
61
|
+
...(this.buttons
|
|
62
|
+
? [{
|
|
63
|
+
type: 'actions',
|
|
64
|
+
elements: [
|
|
65
|
+
{ type: 'button', text: { type: 'plain_text', text: 'Approve' }, style: 'primary', action_id: 'approve', value: pendingId },
|
|
66
|
+
{ type: 'button', text: { type: 'plain_text', text: 'Deny' }, style: 'danger', action_id: 'deny', value: pendingId },
|
|
67
|
+
],
|
|
68
|
+
}]
|
|
69
|
+
: []),
|
|
51
70
|
]);
|
|
52
71
|
return { pendingId };
|
|
53
72
|
}
|
|
@@ -60,8 +79,10 @@ export class SlackNotifier {
|
|
|
60
79
|
action_id: 'answer_option',
|
|
61
80
|
}));
|
|
62
81
|
this.postMessage(`Question from ${agent}: ${question}`, [
|
|
63
|
-
{ type: 'section', text: { type: 'mrkdwn', text: `*Question from ${agent}${sessionId ? ` (session ${sessionId})` : ''}:*\n${question}` } },
|
|
64
|
-
|
|
82
|
+
{ type: 'section', text: { type: 'mrkdwn', text: `*Question from ${agent}${sessionId ? ` (session ${sessionId})` : ''}:*\n${question}${this.decideLine(pendingId, 'question')}` } },
|
|
83
|
+
...(this.buttons
|
|
84
|
+
? [{ type: 'actions', elements: [...optionButtons, { type: 'button', text: { type: 'plain_text', text: 'Other…' }, action_id: 'open_answer_modal', value: pendingId }] }]
|
|
85
|
+
: []),
|
|
65
86
|
]);
|
|
66
87
|
return { pendingId };
|
|
67
88
|
}
|
|
@@ -5,14 +5,22 @@ export interface DeliveryResult {
|
|
|
5
5
|
status?: number;
|
|
6
6
|
error?: string;
|
|
7
7
|
}
|
|
8
|
+
/** Where this server is reachable: LOOPENGINE_PUBLIC_URL (e.g.
|
|
9
|
+
* https://acme.loopengine.app) when set, read per call so a change takes
|
|
10
|
+
* effect without a restart, else `fallback` (a notifier.yml's own
|
|
11
|
+
* publicUrl, remembered from the console). undefined when neither is
|
|
12
|
+
* known, in which case payloads carry only relative paths. */
|
|
13
|
+
export declare function publicBaseUrl(fallback?: string): string | undefined;
|
|
8
14
|
export declare class WebhookNotifier implements DurableApprover, DurableQuestionHandler {
|
|
9
15
|
private readonly webhookUrl;
|
|
10
16
|
private readonly signingSecret;
|
|
11
17
|
private readonly retryDelaysMs;
|
|
18
|
+
private readonly publicUrl?;
|
|
12
19
|
constructor(options: {
|
|
13
20
|
webhookUrl: string;
|
|
14
21
|
signingSecret: string;
|
|
15
22
|
retryDelaysMs?: number[];
|
|
23
|
+
publicUrl?: string;
|
|
16
24
|
});
|
|
17
25
|
requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
|
|
18
26
|
pendingId: string;
|
|
@@ -48,18 +48,24 @@
|
|
|
48
48
|
// working unchanged.
|
|
49
49
|
import { createHmac, randomUUID } from 'node:crypto';
|
|
50
50
|
import { recordDelivery } from '../notifier-deliveries.js';
|
|
51
|
+
import { decidePath } from '../approval-links.js';
|
|
51
52
|
/** Waits before each retry after the first attempt — four tries in all,
|
|
52
53
|
* spread over about 40 seconds. */
|
|
53
54
|
const RETRY_DELAYS_MS = [2_000, 10_000, 30_000];
|
|
54
55
|
function sign(body, secret) {
|
|
55
56
|
return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`;
|
|
56
57
|
}
|
|
57
|
-
/** LOOPENGINE_PUBLIC_URL (e.g.
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
/** Where this server is reachable: LOOPENGINE_PUBLIC_URL (e.g.
|
|
59
|
+
* https://acme.loopengine.app) when set, read per call so a change takes
|
|
60
|
+
* effect without a restart, else `fallback` (a notifier.yml's own
|
|
61
|
+
* publicUrl, remembered from the console). undefined when neither is
|
|
62
|
+
* known, in which case payloads carry only relative paths. */
|
|
63
|
+
export function publicBaseUrl(fallback) {
|
|
64
|
+
const base = process.env.LOOPENGINE_PUBLIC_URL || fallback;
|
|
65
|
+
return base ? base.replace(/\/+$/, '') : undefined;
|
|
66
|
+
}
|
|
67
|
+
function absolute(base, path) {
|
|
68
|
+
return base ? base + path : undefined;
|
|
63
69
|
}
|
|
64
70
|
async function attemptOnce(url, body, headers) {
|
|
65
71
|
try {
|
|
@@ -91,18 +97,24 @@ export class WebhookNotifier {
|
|
|
91
97
|
webhookUrl;
|
|
92
98
|
signingSecret;
|
|
93
99
|
retryDelaysMs;
|
|
100
|
+
publicUrl;
|
|
94
101
|
constructor(options) {
|
|
95
102
|
this.webhookUrl = options.webhookUrl;
|
|
96
103
|
this.signingSecret = options.signingSecret;
|
|
97
104
|
this.retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS;
|
|
105
|
+
this.publicUrl = options.publicUrl;
|
|
98
106
|
}
|
|
99
107
|
requestDurableApproval(tool, args, scope, reason) {
|
|
100
108
|
const pendingId = randomUUID();
|
|
101
109
|
const requestedAt = new Date().toISOString();
|
|
102
110
|
const resolvePath = `/pending-approvals/${pendingId}/resolve`;
|
|
111
|
+
const decide = decidePath(pendingId, 'approval');
|
|
112
|
+
const base = publicBaseUrl(this.publicUrl);
|
|
103
113
|
const body = JSON.stringify({
|
|
104
114
|
pendingId, tool, args, scope, reason, requestedAt,
|
|
105
|
-
kind: 'approval', agent: scope.agent, resolvePath, resolveUrl:
|
|
115
|
+
kind: 'approval', agent: scope.agent, resolvePath, resolveUrl: absolute(base, resolvePath),
|
|
116
|
+
// A signed link a person can open to decide, no login needed.
|
|
117
|
+
decidePath: decide, decideUrl: absolute(base, decide),
|
|
106
118
|
});
|
|
107
119
|
void deliver(this.webhookUrl, body, { 'X-Actauth-Signature': sign(body, this.signingSecret) }, { agent: scope.agent, kind: 'approval', pendingId }, this.retryDelaysMs);
|
|
108
120
|
return { pendingId };
|
|
@@ -111,9 +123,12 @@ export class WebhookNotifier {
|
|
|
111
123
|
const pendingId = randomUUID();
|
|
112
124
|
const requestedAt = new Date().toISOString();
|
|
113
125
|
const answerPath = `/pending-questions/${pendingId}/answer`;
|
|
126
|
+
const decide = decidePath(pendingId, 'question');
|
|
127
|
+
const base = publicBaseUrl(this.publicUrl);
|
|
114
128
|
const body = JSON.stringify({
|
|
115
129
|
pendingId, question, options, agent, sessionId, requestedAt,
|
|
116
|
-
kind: 'question', answerPath, answerUrl:
|
|
130
|
+
kind: 'question', answerPath, answerUrl: absolute(base, answerPath),
|
|
131
|
+
decidePath: decide, decideUrl: absolute(base, decide),
|
|
117
132
|
});
|
|
118
133
|
void deliver(this.webhookUrl, body, { 'X-Askuser-Signature': sign(body, this.signingSecret) }, { agent, kind: 'question', pendingId }, this.retryDelaysMs);
|
|
119
134
|
return { pendingId };
|
|
@@ -10,9 +10,17 @@ export interface NotifierFile {
|
|
|
10
10
|
/** lark: app id and chat to post into. */
|
|
11
11
|
appId?: string;
|
|
12
12
|
chatId?: string;
|
|
13
|
+
/** Where this server is reachable, for the links in each message —
|
|
14
|
+
* remembered from the console's own address when saving.
|
|
15
|
+
* LOOPENGINE_PUBLIC_URL, when set, wins over it. */
|
|
16
|
+
publicUrl?: string;
|
|
13
17
|
}
|
|
14
18
|
/** The env var each channel's secret is read from, per agent. */
|
|
15
19
|
export declare const NOTIFIER_SECRET_VARS: Record<NotifierFileChannel, string>;
|
|
20
|
+
/** Optional: a Slack app's signing secret, per agent. Set, it turns on
|
|
21
|
+
* Approve/Deny buttons in Slack, handled by the server itself at
|
|
22
|
+
* POST /hooks/slack/<agent>; unset, messages carry only the decision link. */
|
|
23
|
+
export declare const NOTIFIER_SLACK_SIGNING_SECRET_VAR = "LOOPENGINE_NOTIFIER_SLACK_SIGNING_SECRET";
|
|
16
24
|
export declare class NotifierFileError extends Error {
|
|
17
25
|
}
|
|
18
26
|
export declare function notifierFilePath(dir: string): string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// agents/<name>/notifier.yml — where an agent's "ask" approvals and
|
|
2
2
|
// questions go when nobody is watching a live chat, set from the console
|
|
3
|
-
// (Permissions →
|
|
3
|
+
// (Permissions → "Where approval requests and questions are sent") instead of in code. Only consulted when the
|
|
4
4
|
// agent's own AgentConfig.httpNotifier is unset; code always wins.
|
|
5
5
|
//
|
|
6
6
|
// The file holds only non-secret settings. The one secret each channel
|
|
@@ -17,6 +17,10 @@ export const NOTIFIER_SECRET_VARS = {
|
|
|
17
17
|
slack: 'LOOPENGINE_NOTIFIER_SLACK_BOT_TOKEN',
|
|
18
18
|
lark: 'LOOPENGINE_NOTIFIER_LARK_APP_SECRET',
|
|
19
19
|
};
|
|
20
|
+
/** Optional: a Slack app's signing secret, per agent. Set, it turns on
|
|
21
|
+
* Approve/Deny buttons in Slack, handled by the server itself at
|
|
22
|
+
* POST /hooks/slack/<agent>; unset, messages carry only the decision link. */
|
|
23
|
+
export const NOTIFIER_SLACK_SIGNING_SECRET_VAR = 'LOOPENGINE_NOTIFIER_SLACK_SIGNING_SECRET';
|
|
20
24
|
const CHANNELS = ['webhook', 'slack', 'lark'];
|
|
21
25
|
const EVENTS = ['approval', 'question', 'run_start', 'run_finish'];
|
|
22
26
|
export class NotifierFileError extends Error {
|
|
@@ -38,6 +42,12 @@ export function validateNotifierFile(input) {
|
|
|
38
42
|
}
|
|
39
43
|
const str = (key) => (typeof raw[key] === 'string' && raw[key].trim() ? raw[key].trim() : undefined);
|
|
40
44
|
const file = { channel, events: events };
|
|
45
|
+
const publicUrl = str('publicUrl');
|
|
46
|
+
if (publicUrl) {
|
|
47
|
+
if (!/^https?:\/\/[^/]+/.test(publicUrl))
|
|
48
|
+
throw new NotifierFileError('publicUrl must be an http(s) URL');
|
|
49
|
+
file.publicUrl = publicUrl.replace(/\/+$/, '');
|
|
50
|
+
}
|
|
41
51
|
if (channel === 'webhook') {
|
|
42
52
|
const url = str('url');
|
|
43
53
|
if (!url || !/^https?:\/\//.test(url))
|
|
@@ -69,8 +79,8 @@ export function readNotifierFile(dir) {
|
|
|
69
79
|
}
|
|
70
80
|
export function writeNotifierFile(dir, input) {
|
|
71
81
|
const file = validateNotifierFile(input);
|
|
72
|
-
const header = '# Where this agent sends
|
|
73
|
-
`# Edited from the console (Permissions
|
|
82
|
+
const header = '# Where this agent sends approval requests and questions when it is called over HTTP.\n' +
|
|
83
|
+
`# Edited from the console (Permissions tab). The secret lives in this agent's env as ${NOTIFIER_SECRET_VARS[file.channel]}.\n`;
|
|
74
84
|
writeFileSync(notifierFilePath(dir), header + stringifyYaml(file));
|
|
75
85
|
return file;
|
|
76
86
|
}
|
|
@@ -87,10 +97,21 @@ export function notifierConfigFromFile(file, env) {
|
|
|
87
97
|
return { missingSecret: secretVar };
|
|
88
98
|
switch (file.channel) {
|
|
89
99
|
case 'webhook':
|
|
90
|
-
return { config: { channel: 'webhook', config: { webhookUrl: file.url, webhookSecret: secret }, events: file.events } };
|
|
100
|
+
return { config: { channel: 'webhook', config: { webhookUrl: file.url, webhookSecret: secret, publicUrl: file.publicUrl }, events: file.events } };
|
|
91
101
|
case 'slack':
|
|
92
|
-
|
|
102
|
+
// Buttons only once the agent has a signing secret, i.e. the Slack
|
|
103
|
+
// app's interactivity points at /hooks/slack/<agent>; otherwise the
|
|
104
|
+
// decision link does the job.
|
|
105
|
+
return {
|
|
106
|
+
config: {
|
|
107
|
+
channel: 'slack',
|
|
108
|
+
config: { botToken: secret, channelId: file.channelId, publicUrl: file.publicUrl, buttons: Boolean(env.get(NOTIFIER_SLACK_SIGNING_SECRET_VAR)) },
|
|
109
|
+
events: file.events,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
93
112
|
case 'lark':
|
|
94
|
-
|
|
113
|
+
// No card buttons: nothing built in handles Lark's card callbacks yet,
|
|
114
|
+
// so a console-configured Lark notifier relies on the decision link.
|
|
115
|
+
return { config: { channel: 'lark', config: { appId: file.appId, appSecret: secret, chatId: file.chatId, publicUrl: file.publicUrl, buttons: false }, events: file.events } };
|
|
95
116
|
}
|
|
96
117
|
}
|