loopengine 0.1.66 → 0.1.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,56 +39,109 @@
39
39
  // pending-approval/pending-question one hitting the same URL (see
40
40
  // examples/notifier-handler/webhook.ts's own `verifyWebhookNotifier` for
41
41
  // the receiving side of all three).
42
+ // Delivery is retried (RETRY_DELAYS_MS, a non-2xx answer counts as a
43
+ // failure), every attempt is recorded for the console's delivery log
44
+ // (core/notifier-deliveries.ts), and each payload carries where to send
45
+ // the decision back (`resolvePath`/`answerPath`, plus a full
46
+ // `resolveUrl`/`answerUrl` when LOOPENGINE_PUBLIC_URL says where this
47
+ // server is reachable) — all additive fields, so existing receivers keep
48
+ // working unchanged.
42
49
  import { createHmac, randomUUID } from 'node:crypto';
50
+ import { recordDelivery } from '../notifier-deliveries.js';
51
+ import { decidePath } from '../approval-links.js';
52
+ /** Waits before each retry after the first attempt — four tries in all,
53
+ * spread over about 40 seconds. */
54
+ const RETRY_DELAYS_MS = [2_000, 10_000, 30_000];
55
+ function sign(body, secret) {
56
+ return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`;
57
+ }
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;
69
+ }
70
+ async function attemptOnce(url, body, headers) {
71
+ try {
72
+ const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body });
73
+ return res.ok ? { ok: true, status: res.status } : { ok: false, status: res.status, error: `HTTP ${res.status}` };
74
+ }
75
+ catch (err) {
76
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
77
+ }
78
+ }
79
+ /** Sends one signed POST, retrying on a network error or non-2xx answer,
80
+ * and records every attempt. Resolves with the last attempt's result —
81
+ * callers that must return right away (a pending decision is already
82
+ * recorded either way) don't await it. */
83
+ async function deliver(url, body, headers, meta, retryDelaysMs) {
84
+ let result = { ok: false };
85
+ for (let attempt = 1; attempt <= retryDelaysMs.length + 1; attempt++) {
86
+ if (attempt > 1)
87
+ await new Promise((resolve) => setTimeout(resolve, retryDelaysMs[attempt - 2]));
88
+ result = await attemptOnce(url, body, headers);
89
+ recordDelivery({ at: new Date().toISOString(), url, attempt, ...meta, ...result });
90
+ if (result.ok)
91
+ return result;
92
+ }
93
+ console.error(`[loopengine] webhook delivery to ${url} failed after ${retryDelaysMs.length + 1} attempts (${meta.kind}${meta.pendingId ? ` ${meta.pendingId}` : ''}): ${result.error}`);
94
+ return result;
95
+ }
43
96
  export class WebhookNotifier {
44
97
  webhookUrl;
45
98
  signingSecret;
99
+ retryDelaysMs;
100
+ publicUrl;
46
101
  constructor(options) {
47
102
  this.webhookUrl = options.webhookUrl;
48
103
  this.signingSecret = options.signingSecret;
104
+ this.retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS;
105
+ this.publicUrl = options.publicUrl;
49
106
  }
50
107
  requestDurableApproval(tool, args, scope, reason) {
51
108
  const pendingId = randomUUID();
52
109
  const requestedAt = new Date().toISOString();
53
- const body = JSON.stringify({ pendingId, tool, args, scope, reason, requestedAt });
54
- this.post(body, 'X-Actauth-Signature', pendingId);
110
+ const resolvePath = `/pending-approvals/${pendingId}/resolve`;
111
+ const decide = decidePath(pendingId, 'approval');
112
+ const base = publicBaseUrl(this.publicUrl);
113
+ const body = JSON.stringify({
114
+ pendingId, tool, args, scope, reason, requestedAt,
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),
118
+ });
119
+ void deliver(this.webhookUrl, body, { 'X-Actauth-Signature': sign(body, this.signingSecret) }, { agent: scope.agent, kind: 'approval', pendingId }, this.retryDelaysMs);
55
120
  return { pendingId };
56
121
  }
57
122
  notifyPendingQuestion(question, options, agent, sessionId) {
58
123
  const pendingId = randomUUID();
59
124
  const requestedAt = new Date().toISOString();
60
- const body = JSON.stringify({ pendingId, question, options, agent, sessionId, requestedAt });
61
- this.post(body, 'X-Askuser-Signature', pendingId);
62
- return { pendingId };
63
- }
64
- /** Shared by both methods above — the one place either used to
65
- * independently build the same signed fetch() call. Not awaited by its
66
- * own callers, same fire-and-forget contract each had on its own: both
67
- * requestDurableApproval/notifyPendingQuestion return immediately, by
68
- * contract (see DurableApprover/DurableQuestionHandler's own doc
69
- * comments), so a delivery failure here has no synchronous way to
70
- * surface to the caller — logged instead of thrown, since throwing
71
- * would blow up a decision that has already, correctly, been recorded
72
- * as pending. */
73
- post(body, signatureHeader, pendingId) {
74
- const signature = `sha256=${createHmac('sha256', this.signingSecret).update(body).digest('hex')}`;
75
- fetch(this.webhookUrl, {
76
- method: 'POST',
77
- headers: { 'content-type': 'application/json', [signatureHeader]: signature },
78
- body,
79
- }).catch((err) => {
80
- console.error(`[loopengine] WebhookNotifier: webhook delivery failed for pendingId '${pendingId}':`, err);
125
+ const answerPath = `/pending-questions/${pendingId}/answer`;
126
+ const decide = decidePath(pendingId, 'question');
127
+ const base = publicBaseUrl(this.publicUrl);
128
+ const body = JSON.stringify({
129
+ pendingId, question, options, agent, sessionId, requestedAt,
130
+ kind: 'question', answerPath, answerUrl: absolute(base, answerPath),
131
+ decidePath: decide, decideUrl: absolute(base, decide),
81
132
  });
133
+ void deliver(this.webhookUrl, body, { 'X-Askuser-Signature': sign(body, this.signingSecret) }, { agent, kind: 'question', pendingId }, this.retryDelaysMs);
134
+ return { pendingId };
82
135
  }
83
136
  }
84
137
  export function postLifecycleWebhook(webhookUrl, webhookSecret, event, context) {
85
138
  const payload = JSON.stringify({ event, ...context, occurredAt: new Date().toISOString() });
86
- const signature = `sha256=${createHmac('sha256', webhookSecret).update(payload).digest('hex')}`;
87
- fetch(webhookUrl, {
88
- method: 'POST',
89
- headers: { 'content-type': 'application/json', 'X-Lifecycle-Signature': signature },
90
- body: payload,
91
- }).catch((err) => {
92
- console.error(`[loopengine] httpNotifier ${event} webhook delivery failed:`, err);
93
- });
139
+ void deliver(webhookUrl, payload, { 'X-Lifecycle-Signature': sign(payload, webhookSecret) }, { agent: String(context.agent ?? ''), kind: event }, RETRY_DELAYS_MS);
140
+ }
141
+ /** One signed `{event: "test"}` POST (lifecycle signature header), no
142
+ * retries, awaited — backs the console's "Send test" button so an
143
+ * operator sees right away whether the receiver accepts it. */
144
+ export function sendTestWebhook(webhookUrl, webhookSecret, agent) {
145
+ const payload = JSON.stringify({ event: 'test', agent, occurredAt: new Date().toISOString() });
146
+ return deliver(webhookUrl, payload, { 'X-Lifecycle-Signature': sign(payload, webhookSecret) }, { agent, kind: 'test' }, []);
94
147
  }
@@ -0,0 +1,18 @@
1
+ export type NotifierDeliveryKind = 'approval' | 'question' | 'run_start' | 'run_finish' | 'test';
2
+ export interface NotifierDelivery {
3
+ at: string;
4
+ agent: string;
5
+ kind: NotifierDeliveryKind;
6
+ url: string;
7
+ pendingId?: string;
8
+ /** 1 for the first try, 2+ for retries. */
9
+ attempt: number;
10
+ ok: boolean;
11
+ /** The receiver's HTTP status, when it answered at all. */
12
+ status?: number;
13
+ error?: string;
14
+ }
15
+ export declare function recordDelivery(delivery: NotifierDelivery): void;
16
+ /** Newest first. */
17
+ export declare function listDeliveries(agent: string): NotifierDelivery[];
18
+ export declare function clearDeliveries(): void;
@@ -0,0 +1,22 @@
1
+ // A short, in-memory record of recent webhook deliveries per agent — what
2
+ // the console's Approvals section shows so an operator can see whether a
3
+ // notification actually reached the app on the other end (and why not).
4
+ // Deliberately not persisted: it's a debugging aid that resets with the
5
+ // process, not an audit log; the pending decision itself is what's
6
+ // durable (core/durable-approvals.ts).
7
+ const MAX_PER_AGENT = 50;
8
+ const byAgent = new Map();
9
+ export function recordDelivery(delivery) {
10
+ const list = byAgent.get(delivery.agent) ?? [];
11
+ list.unshift(delivery);
12
+ if (list.length > MAX_PER_AGENT)
13
+ list.length = MAX_PER_AGENT;
14
+ byAgent.set(delivery.agent, list);
15
+ }
16
+ /** Newest first. */
17
+ export function listDeliveries(agent) {
18
+ return [...(byAgent.get(agent) ?? [])];
19
+ }
20
+ export function clearDeliveries() {
21
+ byAgent.clear();
22
+ }
@@ -0,0 +1,35 @@
1
+ import type { AgentEnv, HttpNotifierConfig, HttpNotifierEvent } from '#core/agent-config.js';
2
+ export type NotifierFileChannel = 'webhook' | 'slack' | 'lark';
3
+ export interface NotifierFile {
4
+ channel: NotifierFileChannel;
5
+ events: HttpNotifierEvent[];
6
+ /** webhook: where to POST. */
7
+ url?: string;
8
+ /** slack: channel to post into. */
9
+ channelId?: string;
10
+ /** lark: app id and chat to post into. */
11
+ appId?: string;
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;
17
+ }
18
+ /** The env var each channel's secret is read from, per agent. */
19
+ export declare const NOTIFIER_SECRET_VARS: Record<NotifierFileChannel, string>;
20
+ export declare class NotifierFileError extends Error {
21
+ }
22
+ export declare function notifierFilePath(dir: string): string;
23
+ /** Throws NotifierFileError naming the first problem. */
24
+ export declare function validateNotifierFile(input: unknown): NotifierFile;
25
+ /** undefined when the agent has no notifier.yml. */
26
+ export declare function readNotifierFile(dir: string): NotifierFile | undefined;
27
+ export declare function writeNotifierFile(dir: string, input: unknown): NotifierFile;
28
+ export declare function removeNotifierFile(dir: string): void;
29
+ /** The HttpNotifierConfig a notifier.yml describes, with its secret read
30
+ * from `env` — or which secret is missing, so nothing is sent unsigned
31
+ * or unauthenticated. */
32
+ export declare function notifierConfigFromFile(file: NotifierFile, env: AgentEnv): {
33
+ config?: HttpNotifierConfig;
34
+ missingSecret?: string;
35
+ };
@@ -0,0 +1,104 @@
1
+ // agents/<name>/notifier.yml — where an agent's "ask" approvals and
2
+ // questions go when nobody is watching a live chat, set from the console
3
+ // (Permissions → "Where approval requests and questions are sent") instead of in code. Only consulted when the
4
+ // agent's own AgentConfig.httpNotifier is unset; code always wins.
5
+ //
6
+ // The file holds only non-secret settings. The one secret each channel
7
+ // needs (a webhook signing secret, a Slack bot token, a Lark app secret)
8
+ // lives in the agent's own env (agents/<name>/.env by default, through
9
+ // core/secret-store.ts) under a fixed name, so it's never written into a
10
+ // file that might get committed.
11
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
14
+ /** The env var each channel's secret is read from, per agent. */
15
+ export const NOTIFIER_SECRET_VARS = {
16
+ webhook: 'LOOPENGINE_NOTIFIER_WEBHOOK_SECRET',
17
+ slack: 'LOOPENGINE_NOTIFIER_SLACK_BOT_TOKEN',
18
+ lark: 'LOOPENGINE_NOTIFIER_LARK_APP_SECRET',
19
+ };
20
+ const CHANNELS = ['webhook', 'slack', 'lark'];
21
+ const EVENTS = ['approval', 'question', 'run_start', 'run_finish'];
22
+ export class NotifierFileError extends Error {
23
+ }
24
+ export function notifierFilePath(dir) {
25
+ return join(dir, 'notifier.yml');
26
+ }
27
+ /** Throws NotifierFileError naming the first problem. */
28
+ export function validateNotifierFile(input) {
29
+ if (!input || typeof input !== 'object')
30
+ throw new NotifierFileError('notifier settings must be an object');
31
+ const raw = input;
32
+ const channel = raw.channel;
33
+ if (!CHANNELS.includes(channel))
34
+ throw new NotifierFileError(`channel must be one of: ${CHANNELS.join(', ')}`);
35
+ const events = Array.isArray(raw.events) ? raw.events : [];
36
+ if (!events.length || events.some((e) => !EVENTS.includes(e))) {
37
+ throw new NotifierFileError(`events must be a non-empty list of: ${EVENTS.join(', ')}`);
38
+ }
39
+ const str = (key) => (typeof raw[key] === 'string' && raw[key].trim() ? raw[key].trim() : undefined);
40
+ const file = { channel, events: events };
41
+ const publicUrl = str('publicUrl');
42
+ if (publicUrl) {
43
+ if (!/^https?:\/\/[^/]+/.test(publicUrl))
44
+ throw new NotifierFileError('publicUrl must be an http(s) URL');
45
+ file.publicUrl = publicUrl.replace(/\/+$/, '');
46
+ }
47
+ if (channel === 'webhook') {
48
+ const url = str('url');
49
+ if (!url || !/^https?:\/\//.test(url))
50
+ throw new NotifierFileError('url must be an http(s) URL');
51
+ file.url = url;
52
+ }
53
+ else if (channel === 'slack') {
54
+ const channelId = str('channelId');
55
+ if (!channelId)
56
+ throw new NotifierFileError('channelId is required for slack');
57
+ file.channelId = channelId;
58
+ }
59
+ else {
60
+ const appId = str('appId');
61
+ const chatId = str('chatId');
62
+ if (!appId || !chatId)
63
+ throw new NotifierFileError('appId and chatId are required for lark');
64
+ file.appId = appId;
65
+ file.chatId = chatId;
66
+ }
67
+ return file;
68
+ }
69
+ /** undefined when the agent has no notifier.yml. */
70
+ export function readNotifierFile(dir) {
71
+ const path = notifierFilePath(dir);
72
+ if (!existsSync(path))
73
+ return undefined;
74
+ return validateNotifierFile(parseYaml(readFileSync(path, 'utf8')));
75
+ }
76
+ export function writeNotifierFile(dir, input) {
77
+ const file = validateNotifierFile(input);
78
+ const header = '# Where this agent sends approval requests and questions when it is called over HTTP.\n' +
79
+ `# Edited from the console (Permissions tab). The secret lives in this agent's env as ${NOTIFIER_SECRET_VARS[file.channel]}.\n`;
80
+ writeFileSync(notifierFilePath(dir), header + stringifyYaml(file));
81
+ return file;
82
+ }
83
+ export function removeNotifierFile(dir) {
84
+ rmSync(notifierFilePath(dir), { force: true });
85
+ }
86
+ /** The HttpNotifierConfig a notifier.yml describes, with its secret read
87
+ * from `env` — or which secret is missing, so nothing is sent unsigned
88
+ * or unauthenticated. */
89
+ export function notifierConfigFromFile(file, env) {
90
+ const secretVar = NOTIFIER_SECRET_VARS[file.channel];
91
+ const secret = env.get(secretVar);
92
+ if (!secret)
93
+ return { missingSecret: secretVar };
94
+ switch (file.channel) {
95
+ case 'webhook':
96
+ return { config: { channel: 'webhook', config: { webhookUrl: file.url, webhookSecret: secret, publicUrl: file.publicUrl }, events: file.events } };
97
+ case 'slack':
98
+ // No interactive buttons: a console-configured Slack notifier has
99
+ // no interactivity endpoint behind it; the decision link replaces them.
100
+ return { config: { channel: 'slack', config: { botToken: secret, channelId: file.channelId, publicUrl: file.publicUrl, buttons: false }, events: file.events } };
101
+ case 'lark':
102
+ return { config: { channel: 'lark', config: { appId: file.appId, appSecret: secret, chatId: file.chatId }, events: file.events } };
103
+ }
104
+ }
package/dist/index.d.ts CHANGED
@@ -7,6 +7,13 @@ export { createAgentEnv, agentDirFor, envScopeOf, type EnvScope } from '#core/ag
7
7
  export { FileSecretStore, setSecretStore, getSecretStore, type SecretStore } from '#core/secret-store.js';
8
8
  export { signProxyIdentity, verifyProxyIdentity, TRUSTED_PROXY_IDENTITY_HEADER, type ProxyIdentity } from '#core/trusted-proxy.js';
9
9
  export { setUsageSink, recordUsage, type UsageEvent, type UsageSink } from '#core/usage.js';
10
+ export { readNotifierFile, writeNotifierFile, removeNotifierFile, validateNotifierFile, notifierConfigFromFile, NotifierFileError, NOTIFIER_SECRET_VARS, type NotifierFile } from '#core/notifier-file.js';
11
+ export { listDeliveries, recordDelivery, type NotifierDelivery } from '#core/notifier-deliveries.js';
12
+ export { describeNotifier } from '#core/http-notifier.js';
13
+ export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs, type DecisionKind } from '#core/approval-links.js';
14
+ export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage, type DecidePageItem } from './web/decide-page.js';
15
+ export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
16
+ export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, type DefaultWebhook } from './web/notifier-admin.js';
10
17
  export type * from './core/loop-events.js';
11
18
  export { streamMessage, streamMessageWithCallbacks, sendMessage, approveCall, denyCall, answerQuestion, getSessionHistory, } from './core/client.js';
12
19
  export type { RequestOptions, SendMessageResult, PendingApprovalResult, PendingQuestionResult, PendingResult, MessageResult, StreamMessageCallbacks, } from './core/client.js';
package/dist/index.js CHANGED
@@ -9,6 +9,13 @@ export { createAgentEnv, agentDirFor, envScopeOf } from '#core/agent-env.js';
9
9
  export { FileSecretStore, setSecretStore, getSecretStore } from '#core/secret-store.js';
10
10
  export { signProxyIdentity, verifyProxyIdentity, TRUSTED_PROXY_IDENTITY_HEADER } from '#core/trusted-proxy.js';
11
11
  export { setUsageSink, recordUsage } from '#core/usage.js';
12
+ export { readNotifierFile, writeNotifierFile, removeNotifierFile, validateNotifierFile, notifierConfigFromFile, NotifierFileError, NOTIFIER_SECRET_VARS } from '#core/notifier-file.js';
13
+ export { listDeliveries, recordDelivery } from '#core/notifier-deliveries.js';
14
+ export { describeNotifier } from '#core/http-notifier.js';
15
+ export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs } from '#core/approval-links.js';
16
+ export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage } from './web/decide-page.js';
17
+ export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
18
+ export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending } from './web/notifier-admin.js';
12
19
  // The framework-agnostic browser client for adapters/http.ts's own wire
13
20
  // protocol (see client.ts's own header comment) — this, not a deep
14
21
  // subpath import, is how a consumer's React/Vue/etc. chat UI is meant to