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 CHANGED
@@ -171,11 +171,25 @@ export const config: AgentConfig = {
171
171
  }
172
172
  ```
173
173
 
174
- Or skip the code: in the console, **Permissions → Approvals** sets a
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
- tag: 'action',
88
- actions: [
89
- { tag: 'button', text: { tag: 'plain_text', content: 'Approve' }, type: 'primary', value: { pendingId, decision: 'approve' } },
90
- { tag: 'button', text: { tag: 'plain_text', content: 'Deny' }, type: 'danger', value: { pendingId, decision: 'deny' } },
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
- ...(optionButtons.length ? [{ tag: 'action', actions: optionButtons }] : []),
119
- {
120
- tag: 'form',
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: 'action',
126
- actions: [{ tag: 'button', text: { tag: 'plain_text', content: 'Send' }, type: 'primary', action_type: 'form_submit', value: { pendingId } }],
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
- type: 'actions',
46
- elements: [
47
- { type: 'button', text: { type: 'plain_text', text: 'Approve' }, style: 'primary', action_id: 'approve', value: pendingId },
48
- { type: 'button', text: { type: 'plain_text', text: 'Deny' }, style: 'danger', action_id: 'deny', value: pendingId },
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
- { type: 'actions', elements: [...optionButtons, { type: 'button', text: { type: 'plain_text', text: 'Other…' }, action_id: 'open_answer_modal', value: pendingId }] },
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. https://acme.loopengine.app), read per
58
- * call so a change takes effect without a restart; undefined when unset,
59
- * in which case payloads carry only the relative path. */
60
- function publicUrl(path) {
61
- const base = process.env.LOOPENGINE_PUBLIC_URL;
62
- return base ? base.replace(/\/+$/, '') + path : undefined;
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: publicUrl(resolvePath),
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: publicUrl(answerPath),
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 → Approvals) instead of in code. Only consulted when the
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 "ask" approvals and questions when nobody is in a live chat.\n' +
73
- `# Edited from the console (Permissions > Approvals). The secret lives in this agent's env as ${NOTIFIER_SECRET_VARS[file.channel]}.\n`;
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
- return { config: { channel: 'slack', config: { botToken: secret, channelId: file.channelId }, events: file.events } };
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
- return { config: { channel: 'lark', config: { appId: file.appId, appSecret: secret, chatId: file.chatId }, events: file.events } };
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
  }