loopengine 0.1.67 → 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.
- package/README.md +11 -2
- package/dist/core/agent-config.d.ts +3 -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 +4 -4
- 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 +4 -0
- package/dist/core/notifier-file.js +13 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/web/agents-config-page.js +65 -16
- package/dist/web/decide-page.d.ts +16 -0
- package/dist/web/decide-page.js +138 -0
- package/dist/web/notifier-admin.d.ts +2 -0
- package/dist/web/notifier-admin.js +3 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -171,11 +171,20 @@ 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: paste a bot token,
|
|
186
|
+
pick a channel, save; each approval arrives with an "Open to approve or
|
|
187
|
+
deny" link.
|
|
179
188
|
|
|
180
189
|
For a third-party app calling an agent over plain HTTP:
|
|
181
190
|
|
|
@@ -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
|
} | {
|
|
@@ -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,
|
|
@@ -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,6 +10,10 @@ 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>;
|
|
@@ -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
|
|
@@ -38,6 +38,12 @@ export function validateNotifierFile(input) {
|
|
|
38
38
|
}
|
|
39
39
|
const str = (key) => (typeof raw[key] === 'string' && raw[key].trim() ? raw[key].trim() : undefined);
|
|
40
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
|
+
}
|
|
41
47
|
if (channel === 'webhook') {
|
|
42
48
|
const url = str('url');
|
|
43
49
|
if (!url || !/^https?:\/\//.test(url))
|
|
@@ -69,8 +75,8 @@ export function readNotifierFile(dir) {
|
|
|
69
75
|
}
|
|
70
76
|
export function writeNotifierFile(dir, input) {
|
|
71
77
|
const file = validateNotifierFile(input);
|
|
72
|
-
const header = '# Where this agent sends
|
|
73
|
-
`# Edited from the console (Permissions
|
|
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`;
|
|
74
80
|
writeFileSync(notifierFilePath(dir), header + stringifyYaml(file));
|
|
75
81
|
return file;
|
|
76
82
|
}
|
|
@@ -87,9 +93,11 @@ export function notifierConfigFromFile(file, env) {
|
|
|
87
93
|
return { missingSecret: secretVar };
|
|
88
94
|
switch (file.channel) {
|
|
89
95
|
case 'webhook':
|
|
90
|
-
return { config: { channel: 'webhook', config: { webhookUrl: file.url, webhookSecret: secret }, events: file.events } };
|
|
96
|
+
return { config: { channel: 'webhook', config: { webhookUrl: file.url, webhookSecret: secret, publicUrl: file.publicUrl }, events: file.events } };
|
|
91
97
|
case 'slack':
|
|
92
|
-
|
|
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 } };
|
|
93
101
|
case 'lark':
|
|
94
102
|
return { config: { channel: 'lark', config: { appId: file.appId, appSecret: secret, chatId: file.chatId }, events: file.events } };
|
|
95
103
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export { setUsageSink, recordUsage, type UsageEvent, type UsageSink } from '#cor
|
|
|
10
10
|
export { readNotifierFile, writeNotifierFile, removeNotifierFile, validateNotifierFile, notifierConfigFromFile, NotifierFileError, NOTIFIER_SECRET_VARS, type NotifierFile } from '#core/notifier-file.js';
|
|
11
11
|
export { listDeliveries, recordDelivery, type NotifierDelivery } from '#core/notifier-deliveries.js';
|
|
12
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';
|
|
13
15
|
export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
|
|
14
16
|
export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, type DefaultWebhook } from './web/notifier-admin.js';
|
|
15
17
|
export type * from './core/loop-events.js';
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,8 @@ export { setUsageSink, recordUsage } from '#core/usage.js';
|
|
|
12
12
|
export { readNotifierFile, writeNotifierFile, removeNotifierFile, validateNotifierFile, notifierConfigFromFile, NotifierFileError, NOTIFIER_SECRET_VARS } from '#core/notifier-file.js';
|
|
13
13
|
export { listDeliveries, recordDelivery } from '#core/notifier-deliveries.js';
|
|
14
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';
|
|
15
17
|
export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
|
|
16
18
|
export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending } from './web/notifier-admin.js';
|
|
17
19
|
// The framework-agnostic browser client for adapters/http.ts's own wire
|
|
@@ -39,6 +39,7 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
39
39
|
panels' own content. */
|
|
40
40
|
#empty { color: var(--ink-muted); font-size: 13px; padding: 24px; }
|
|
41
41
|
.env-cell { display: grid; gap: 6px; }
|
|
42
|
+
.approvals-nudge { border: 1px solid var(--warn); background: var(--warn-soft); color: var(--ink); border-radius: var(--radius); padding: 12px 14px; margin-bottom: 24px; font-size: 13px; }
|
|
42
43
|
.env-desc { max-width: 340px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
|
43
44
|
form.add-source.env-inline { border: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; max-width: 320px; }
|
|
44
45
|
form.add-source.env-inline input, form.add-source.env-inline select { flex: 1 1 140px; width: auto; min-width: 0; }
|
|
@@ -955,7 +956,7 @@ ${consoleShellScript}
|
|
|
955
956
|
'</form></section>';
|
|
956
957
|
}
|
|
957
958
|
|
|
958
|
-
// ---- Permissions →
|
|
959
|
+
// ---- Permissions → "Where approval requests and questions are sent": where "ask" decisions go when nobody is
|
|
959
960
|
// in Chat (web/notifier-admin.ts behind GET/PUT/DELETE .../notifier),
|
|
960
961
|
// the decisions still waiting (GET .../pending), and recent webhook
|
|
961
962
|
// deliveries. Reloaded every time the tab opens, so the waiting list is
|
|
@@ -980,6 +981,9 @@ ${consoleShellScript}
|
|
|
980
981
|
? '<form class="pending-answer" data-id="' + escapeHtml(p.pendingId) + '" style="display:flex;gap:6px"><input name="answer" placeholder="Answer" required style="min-width:0"><button type="submit">Answer</button></form>'
|
|
981
982
|
: '<div style="display:flex;gap:6px"><button type="button" class="primary pending-decide" data-id="' + escapeHtml(p.pendingId) + '" data-decision="approve">Approve</button>' +
|
|
982
983
|
'<button type="button" class="pending-decide" data-id="' + escapeHtml(p.pendingId) + '" data-decision="deny">Deny</button></div>';
|
|
984
|
+
if (p.decidePath) {
|
|
985
|
+
actions += '<button type="button" class="pending-copy" data-path="' + escapeHtml(p.decidePath) + '" title="A link anyone can open to decide, no login needed" style="margin-top:6px">Copy link</button>';
|
|
986
|
+
}
|
|
983
987
|
return '<tr><td><span class="le-pill ' + (p.kind === 'question' ? 'accent' : 'ask') + '">' + (p.kind === 'question' ? 'question' : 'approval') + '</span>' +
|
|
984
988
|
(p.createdAt ? '<div class="hint">' + escapeHtml(new Date(p.createdAt).toLocaleString()) + '</div>' : '') + '</td>' +
|
|
985
989
|
'<td>' + details + '</td><td><code>' + escapeHtml(p.sessionId) + '</code></td><td>' + actions + '</td></tr>';
|
|
@@ -991,12 +995,15 @@ ${consoleShellScript}
|
|
|
991
995
|
if (n.source === 'file') return 'Sending to <strong>' + escapeHtml(n.channel) + '</strong> for: ' + escapeHtml((n.events || []).join(', ')) + '.';
|
|
992
996
|
if (n.missingSecret) return 'Saved, but not sending yet: set the secret below (<code>' + escapeHtml(n.missingSecret) + '</code>).';
|
|
993
997
|
if (n.source === 'default') return 'Using the project-wide default webhook (<code>LOOPENGINE_DEFAULT_WEBHOOK_URL</code>, ' + escapeHtml(n.defaultWebhookUrl) + '). Set one below to use a different target for this agent.';
|
|
994
|
-
return '
|
|
998
|
+
return '<strong>Nowhere yet</strong>, so nobody is notified. Choose a webhook, Slack channel or Lark chat below.';
|
|
995
999
|
}
|
|
996
1000
|
|
|
997
1001
|
function renderNotifierForm(n) {
|
|
998
1002
|
var f = n.file || { channel: 'webhook', events: ['approval', 'question'] };
|
|
999
1003
|
var events = f.events || [];
|
|
1004
|
+
function eventBox(e) {
|
|
1005
|
+
return '<label class="tool-picker-item" style="border:0;padding:0;justify-content:flex-start"><input type="checkbox" name="events" value="' + e[0] + '"' + (events.indexOf(e[0]) !== -1 ? ' checked' : '') + '> ' + e[1] + '</label>';
|
|
1006
|
+
}
|
|
1000
1007
|
function field(channel, html) {
|
|
1001
1008
|
return '<div class="notifier-fields" data-channel="' + channel + '"' + (f.channel === channel ? '' : ' hidden') + '>' + html + '</div>';
|
|
1002
1009
|
}
|
|
@@ -1013,15 +1020,18 @@ ${consoleShellScript}
|
|
|
1013
1020
|
field('webhook', '<label>Webhook URL<input type="url" name="url" placeholder="https://your-app.example.com/hooks/loopengine" value="' + escapeHtml(f.url || '') + '"></label>' + secretField('webhook', 'Signing secret')) +
|
|
1014
1021
|
field('slack', '<label>Channel ID<input name="channelId" placeholder="C0123ABC456" value="' + escapeHtml(f.channelId || '') + '"></label>' + secretField('slack', 'Bot token')) +
|
|
1015
1022
|
field('lark', '<label>App ID<input name="appId" value="' + escapeHtml(f.appId || '') + '"></label><label>Chat ID<input name="chatId" value="' + escapeHtml(f.chatId || '') + '"></label>' + secretField('lark', 'App secret')) +
|
|
1016
|
-
'<
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1023
|
+
'<label>Link address <span class="hint">(messages link here so people can approve without logging in; use an address they can reach)</span>' +
|
|
1024
|
+
'<input type="url" name="publicUrl" id="notifierPublicUrl" value="' + escapeHtml(n.publicUrl || f.publicUrl || location.origin) + '"' + (n.publicUrl ? ' disabled title="Set by LOOPENGINE_PUBLIC_URL"' : '') + '></label>' +
|
|
1025
|
+
'<div class="body-field">Send' +
|
|
1026
|
+
'<div style="display:flex;gap:14px;flex-wrap:wrap">' + NOTIFIER_EVENTS.slice(0, 2).map(eventBox).join('') + '</div>' +
|
|
1027
|
+
'<details' + (events.indexOf('run_finish') !== -1 || events.indexOf('run_start') !== -1 ? ' open' : '') + '><summary>More events</summary>' +
|
|
1028
|
+
'<div style="display:flex;gap:14px;flex-wrap:wrap;margin-top:6px">' + NOTIFIER_EVENTS.slice(2).map(eventBox).join('') + '</div>' +
|
|
1029
|
+
'<p class="hint" style="margin:4px 0 0">Run finished tells your app the agent's final reply once a decision lets it finish.</p></details></div>' +
|
|
1030
|
+
(n.canSave === false ? '<p class="error" style="margin:0">Saving needs <code>LOOPENGINE_ADMIN_AUTH</code> set on the server, because this stores a secret. Add <code>LOOPENGINE_ADMIN_AUTH=user:pass</code> to <code>.env</code> and restart.</p>' : '') +
|
|
1031
|
+
'<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit"' + (n.canSave === false ? ' disabled' : '') + '>Save</button><button type="button" id="notifierTestBtn">Send test</button>' +
|
|
1021
1032
|
(n.file ? '<button type="button" class="delete-btn" id="notifierRemoveBtn">Remove</button>' : '') + '</div>' +
|
|
1022
1033
|
'<div id="notifierResult" class="hint"></div>' +
|
|
1023
|
-
'<p class="hint" style="margin:0">
|
|
1024
|
-
(n.publicUrl ? '' : ' Set <code>LOOPENGINE_PUBLIC_URL</code> to include a full <code>resolveUrl</code> too.') + '</p>' +
|
|
1034
|
+
'<p class="hint" style="margin:0">For a webhook: your app checks the <code>X-Actauth-Signature</code> header (HMAC-SHA256 of the body with the signing secret). Each payload carries a <code>decideUrl</code> it can simply forward to a person, or it can decide itself by sending <code>{"decision":"approve"}</code> or <code>{"decision":"deny"}</code> to <code>resolveUrl</code>.</p>' +
|
|
1025
1035
|
'</form>';
|
|
1026
1036
|
}
|
|
1027
1037
|
|
|
@@ -1034,14 +1044,35 @@ ${consoleShellScript}
|
|
|
1034
1044
|
}).join('') + '</tbody></table></div>';
|
|
1035
1045
|
}
|
|
1036
1046
|
|
|
1037
|
-
//
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1047
|
+
// Tools that can stop for approval: a rule that asks, or "ask" as the
|
|
1048
|
+
// decision when no rule matches.
|
|
1049
|
+
function askingTools() {
|
|
1050
|
+
var perms = currentCfg && currentCfg.permissions;
|
|
1051
|
+
if (!perms) return [];
|
|
1052
|
+
var tools = [];
|
|
1053
|
+
perms.rules.forEach(function (r) { if (r.decision === 'ask' && tools.indexOf(r.tool) === -1) tools.push(r.tool); });
|
|
1054
|
+
if (perms.defaultDecision === 'ask') tools.push('any tool without a rule');
|
|
1055
|
+
return tools;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// The top of the tab: a nudge when approvals over HTTP would reach
|
|
1059
|
+
// nobody, and whatever is waiting right now.
|
|
1060
|
+
function renderPendingHtml(pending, n) {
|
|
1061
|
+
var html = '';
|
|
1062
|
+
var tools = askingTools();
|
|
1063
|
+
if (tools.length && n && n.source === 'none') {
|
|
1064
|
+
html += '<div class="approvals-nudge"><strong>Approval requests aren't sent anywhere yet.</strong> ' +
|
|
1065
|
+
escapeHtml(tools.slice(0, 3).join(', ')) + (tools.length > 3 ? ' and more' : '') + ' can stop for approval, but when an app calls this agent over HTTP nobody is notified. ' +
|
|
1066
|
+
'<a href="#approvalsSetup" class="approvals-nudge-link">Choose where they go</a></div>';
|
|
1067
|
+
}
|
|
1068
|
+
if (pending.length) html += '<section><h3>Waiting for a decision (' + pending.length + ')</h3>' + renderPendingTable(pending) + '</section>';
|
|
1069
|
+
return html;
|
|
1041
1070
|
}
|
|
1042
1071
|
|
|
1043
1072
|
function renderApprovalsHtml(n) {
|
|
1044
|
-
return '<section
|
|
1073
|
+
return '<section id="approvalsSetup"><h3>Where approval requests and questions are sent</h3>' +
|
|
1074
|
+
'<p class="muted" style="margin:0 0 8px">When an app calls this agent over HTTP and a tool call needs approval, or the agent asks a question, it's sent here. In Chat it shows up in the conversation instead.</p>' +
|
|
1075
|
+
'<p class="muted" style="margin:0 0 12px">Currently: ' + renderNotifierStatus(n) + '</p>' +
|
|
1045
1076
|
(n.error ? '<p class="error">' + escapeHtml(n.error) + '</p>' : '') +
|
|
1046
1077
|
(n.source === 'code' ? '' : renderNotifierForm(n)) + '</section>' +
|
|
1047
1078
|
'<section><h3>Recent deliveries</h3>' + renderDeliveries(n.deliveries || []) + '</section>';
|
|
@@ -1050,6 +1081,24 @@ ${consoleShellScript}
|
|
|
1050
1081
|
function wirePendingHandlers(name) {
|
|
1051
1082
|
var content = detail.querySelector('#pendingContent');
|
|
1052
1083
|
if (!content) return;
|
|
1084
|
+
var nudgeLink = content.querySelector('.approvals-nudge-link');
|
|
1085
|
+
if (nudgeLink) {
|
|
1086
|
+
nudgeLink.addEventListener('click', function (ev) {
|
|
1087
|
+
ev.preventDefault();
|
|
1088
|
+
var target = detail.querySelector('#approvalsSetup');
|
|
1089
|
+
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
var copyBtns = content.querySelectorAll('.pending-copy');
|
|
1093
|
+
for (var c = 0; c < copyBtns.length; c++) {
|
|
1094
|
+
copyBtns[c].addEventListener('click', function (ev) {
|
|
1095
|
+
var btn = ev.currentTarget;
|
|
1096
|
+
var link = location.origin + btn.dataset.path;
|
|
1097
|
+
var done = function () { btn.textContent = 'Copied'; setTimeout(function () { btn.textContent = 'Copy link'; }, 1500); };
|
|
1098
|
+
if (navigator.clipboard) navigator.clipboard.writeText(link).then(done, function () { window.prompt('Copy this link', link); });
|
|
1099
|
+
else window.prompt('Copy this link', link);
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1053
1102
|
var decideBtns = content.querySelectorAll('.pending-decide');
|
|
1054
1103
|
for (var i = 0; i < decideBtns.length; i++) {
|
|
1055
1104
|
decideBtns[i].addEventListener('click', function (ev) {
|
|
@@ -1097,7 +1146,7 @@ ${consoleShellScript}
|
|
|
1097
1146
|
ev.preventDefault();
|
|
1098
1147
|
var data = new FormData(form);
|
|
1099
1148
|
var channel = data.get('channel');
|
|
1100
|
-
var body = { channel: channel, events: data.getAll('events'), url: data.get('url'), channelId: data.get('channelId'), appId: data.get('appId'), chatId: data.get('chatId') };
|
|
1149
|
+
var body = { channel: channel, events: data.getAll('events'), url: data.get('url'), channelId: data.get('channelId'), appId: data.get('appId'), chatId: data.get('chatId'), publicUrl: data.get('publicUrl') };
|
|
1101
1150
|
var secret = data.get('secret_' + channel);
|
|
1102
1151
|
if (secret) body.secret = secret;
|
|
1103
1152
|
result.textContent = 'Saving…';
|
|
@@ -1137,7 +1186,7 @@ ${consoleShellScript}
|
|
|
1137
1186
|
wireApprovalsHandlers(name);
|
|
1138
1187
|
var pendingEl = detail.querySelector('#pendingContent');
|
|
1139
1188
|
if (pendingEl) {
|
|
1140
|
-
pendingEl.innerHTML = renderPendingHtml((results[1].ok && results[1].body.pending) || []);
|
|
1189
|
+
pendingEl.innerHTML = renderPendingHtml((results[1].ok && results[1].body.pending) || [], results[0].body);
|
|
1141
1190
|
wirePendingHandlers(name);
|
|
1142
1191
|
}
|
|
1143
1192
|
if (message) { var r = content.querySelector('#notifierResult'); if (r) r.textContent = message; }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface DecidePageItem {
|
|
2
|
+
kind: 'approval' | 'question';
|
|
3
|
+
agent: string;
|
|
4
|
+
tool: string;
|
|
5
|
+
args: Record<string, unknown>;
|
|
6
|
+
reason: string;
|
|
7
|
+
sessionId: string;
|
|
8
|
+
tenant: string;
|
|
9
|
+
createdAt?: string;
|
|
10
|
+
expiresAt: number;
|
|
11
|
+
}
|
|
12
|
+
/** A link that's malformed, tampered with, or past its expiry. */
|
|
13
|
+
export declare function renderDecideInvalidPage(): string;
|
|
14
|
+
/** A valid link whose decision has already been made. */
|
|
15
|
+
export declare function renderDecideGonePage(): string;
|
|
16
|
+
export declare function renderDecidePage(item: DecidePageItem): string;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The page a decision link opens (GET /decide/<token>, see
|
|
2
|
+
// core/approval-links.ts): what's waiting — the tool and its arguments,
|
|
3
|
+
// or the question — and buttons to approve, deny or answer. Anyone with
|
|
4
|
+
// the link can decide; the signed token is the only credential, so this
|
|
5
|
+
// page shows nothing beyond the one decision it names.
|
|
6
|
+
//
|
|
7
|
+
// Rendered on the server; the small script only posts the choice back to
|
|
8
|
+
// the same URL (POST /decide/<token>) and shows the outcome. Written
|
|
9
|
+
// without backticks or backslashes inside the script, same convention as
|
|
10
|
+
// the other embedded pages.
|
|
11
|
+
import { devUiCss } from './dev-ui-styles.js';
|
|
12
|
+
function esc(s) {
|
|
13
|
+
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
14
|
+
}
|
|
15
|
+
const PAGE_CSS = `
|
|
16
|
+
body { display: grid; place-items: start center; padding: 48px 16px; background: var(--surface); }
|
|
17
|
+
main { width: 100%; max-width: 560px; background: var(--bg); border: 1px solid var(--line); border-radius: 12px; padding: 24px; display: grid; gap: 16px; }
|
|
18
|
+
.brand { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--ink-muted); font-size: 13px; }
|
|
19
|
+
.mark { width: 18px; height: 18px; border-radius: 5px; background: var(--accent); }
|
|
20
|
+
h1 { font-size: 20px; margin: 0; }
|
|
21
|
+
dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 16px; margin: 0; font-size: 13px; }
|
|
22
|
+
dt { color: var(--ink-faint); }
|
|
23
|
+
dd { margin: 0; overflow-wrap: anywhere; }
|
|
24
|
+
pre, textarea { font-family: var(--font-mono); font-size: 12.5px; background: var(--code-bg); border-radius: 8px; padding: 12px; margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
25
|
+
textarea { width: 100%; min-height: 120px; border: 1px solid var(--line); }
|
|
26
|
+
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
27
|
+
.actions button { padding: 8px 16px; }
|
|
28
|
+
.deny { color: var(--bad); }
|
|
29
|
+
.note { font-size: 12px; color: var(--ink-faint); margin: 0; }
|
|
30
|
+
.result { padding: 12px 14px; border-radius: 8px; background: var(--good-soft); color: var(--good); }
|
|
31
|
+
.result.bad { background: var(--bad-soft); color: var(--bad); }
|
|
32
|
+
.reply { white-space: pre-wrap; background: var(--surface); border-radius: 8px; padding: 12px 14px; }
|
|
33
|
+
details summary { cursor: pointer; font-size: 13px; color: var(--ink-muted); }
|
|
34
|
+
`;
|
|
35
|
+
function shell(title, body, script = '') {
|
|
36
|
+
return `<!doctype html>
|
|
37
|
+
<html lang="en">
|
|
38
|
+
<head>
|
|
39
|
+
<meta charset="utf-8">
|
|
40
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
41
|
+
<meta name="robots" content="noindex">
|
|
42
|
+
<title>${esc(title)}</title>
|
|
43
|
+
<style>${devUiCss}${PAGE_CSS}</style>
|
|
44
|
+
</head>
|
|
45
|
+
<body>
|
|
46
|
+
<main>
|
|
47
|
+
<div class="brand"><span class="mark" aria-hidden="true"></span>LoopEngine</div>
|
|
48
|
+
${body}
|
|
49
|
+
</main>
|
|
50
|
+
${script}
|
|
51
|
+
</body>
|
|
52
|
+
</html>`;
|
|
53
|
+
}
|
|
54
|
+
/** A link that's malformed, tampered with, or past its expiry. */
|
|
55
|
+
export function renderDecideInvalidPage() {
|
|
56
|
+
return shell('Link not valid', '<h1>This link isn't valid</h1><p class="muted">It may have expired or been copied incompletely. Ask for a new one, or decide from the agent's Permissions tab.</p>');
|
|
57
|
+
}
|
|
58
|
+
/** A valid link whose decision has already been made. */
|
|
59
|
+
export function renderDecideGonePage() {
|
|
60
|
+
return shell('Already decided', '<h1>Already decided</h1><p class="muted">Someone has already made this decision, so there's nothing left to do here.</p>');
|
|
61
|
+
}
|
|
62
|
+
const SCRIPT = `<script>
|
|
63
|
+
(function () {
|
|
64
|
+
var main = document.querySelector('main');
|
|
65
|
+
var result = document.getElementById('result');
|
|
66
|
+
function send(body, button) {
|
|
67
|
+
var buttons = main.querySelectorAll('button');
|
|
68
|
+
for (var i = 0; i < buttons.length; i++) buttons[i].disabled = true;
|
|
69
|
+
result.hidden = false;
|
|
70
|
+
result.className = 'result';
|
|
71
|
+
result.textContent = 'Sending' + String.fromCharCode(8230);
|
|
72
|
+
fetch(location.pathname, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
73
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
74
|
+
.then(function (r) {
|
|
75
|
+
if (!r.ok) throw new Error(r.body.error || 'request failed');
|
|
76
|
+
var form = document.getElementById('decision');
|
|
77
|
+
if (form) form.hidden = true;
|
|
78
|
+
result.textContent = r.body.message || (body.decision === 'deny' ? 'Denied.' : body.answer !== undefined ? 'Answer sent.' : 'Approved.');
|
|
79
|
+
if (r.body.text) {
|
|
80
|
+
var reply = document.createElement('div');
|
|
81
|
+
reply.className = 'reply';
|
|
82
|
+
reply.textContent = r.body.text;
|
|
83
|
+
result.after(reply);
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
.catch(function (err) {
|
|
87
|
+
result.className = 'result bad';
|
|
88
|
+
result.textContent = err.message;
|
|
89
|
+
for (var i = 0; i < buttons.length; i++) buttons[i].disabled = false;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
main.addEventListener('click', function (ev) {
|
|
93
|
+
var btn = ev.target.closest('button[data-send]');
|
|
94
|
+
if (!btn) return;
|
|
95
|
+
var payload = JSON.parse(btn.getAttribute('data-send'));
|
|
96
|
+
if (payload.editedFrom) {
|
|
97
|
+
try { payload = { decision: 'approve', editedArgs: JSON.parse(document.getElementById(payload.editedFrom).value) }; }
|
|
98
|
+
catch (e) { result.hidden = false; result.className = 'result bad'; result.textContent = 'The arguments aren' + String.fromCharCode(39) + 't valid JSON.'; return; }
|
|
99
|
+
}
|
|
100
|
+
if (payload.answerFrom) {
|
|
101
|
+
var text = document.getElementById(payload.answerFrom).value.trim();
|
|
102
|
+
if (!text) return;
|
|
103
|
+
payload = { answer: text };
|
|
104
|
+
}
|
|
105
|
+
send(payload, btn);
|
|
106
|
+
});
|
|
107
|
+
})();
|
|
108
|
+
</script>`;
|
|
109
|
+
export function renderDecidePage(item) {
|
|
110
|
+
const expires = new Date(item.expiresAt).toUTCString();
|
|
111
|
+
const facts = `<dl><dt>Agent</dt><dd>${esc(item.agent)}</dd>` +
|
|
112
|
+
`<dt>Conversation</dt><dd><code>${esc(item.sessionId)}</code>${item.tenant && item.tenant !== 'default' ? ` (tenant ${esc(item.tenant)})` : ''}</dd>` +
|
|
113
|
+
(item.createdAt ? `<dt>Asked</dt><dd>${esc(new Date(item.createdAt).toUTCString())}</dd>` : '') +
|
|
114
|
+
`</dl>`;
|
|
115
|
+
const note = `<p class="note">Anyone with this link can make this decision. It works once and expires ${esc(expires)}.</p>`;
|
|
116
|
+
const result = '<div id="result" class="result" hidden role="status"></div>';
|
|
117
|
+
if (item.kind === 'question') {
|
|
118
|
+
const question = typeof item.args.question === 'string' ? item.args.question : item.reason;
|
|
119
|
+
const options = Array.isArray(item.args.options) ? item.args.options.map(String) : [];
|
|
120
|
+
const optionButtons = options.map((o) => `<button type="button" data-send="${esc(JSON.stringify({ answer: o }))}">${esc(o)}</button>`).join('');
|
|
121
|
+
return shell(`Question from ${item.agent}`, `<h1>${esc(item.agent)} has a question</h1>${facts}<pre>${esc(question)}</pre>` +
|
|
122
|
+
`<div id="decision" style="display:grid;gap:10px">` +
|
|
123
|
+
(optionButtons ? `<div class="actions">${optionButtons}</div>` : '') +
|
|
124
|
+
`<label style="display:grid;gap:6px;font-size:13px">${options.length ? 'Or answer in your own words' : 'Your answer'}<textarea id="answerText" style="min-height:80px;font-family:var(--font-sans)"></textarea></label>` +
|
|
125
|
+
`<div class="actions"><button type="button" class="primary" data-send="${esc(JSON.stringify({ answerFrom: 'answerText' }))}">Send answer</button></div></div>` +
|
|
126
|
+
result + note, SCRIPT);
|
|
127
|
+
}
|
|
128
|
+
return shell(`Approve ${item.tool}?`, `<h1>Approve <code>${esc(item.tool)}</code>?</h1>${facts}` +
|
|
129
|
+
`<pre>${esc(JSON.stringify(item.args, null, 2))}</pre>` +
|
|
130
|
+
`<p class="muted" style="margin:0">${esc(item.reason)}</p>` +
|
|
131
|
+
`<div id="decision" style="display:grid;gap:12px">` +
|
|
132
|
+
`<div class="actions"><button type="button" class="primary" data-send="${esc(JSON.stringify({ decision: 'approve' }))}">Approve</button>` +
|
|
133
|
+
`<button type="button" class="deny" data-send="${esc(JSON.stringify({ decision: 'deny' }))}">Deny</button></div>` +
|
|
134
|
+
`<details><summary>Change the arguments first</summary><div style="display:grid;gap:8px;margin-top:8px">` +
|
|
135
|
+
`<textarea id="editedArgs">${esc(JSON.stringify(item.args, null, 2))}</textarea>` +
|
|
136
|
+
`<div class="actions"><button type="button" data-send="${esc(JSON.stringify({ editedFrom: 'editedArgs' }))}">Approve with these arguments</button></div></div></details>` +
|
|
137
|
+
`</div>` + result + note, SCRIPT);
|
|
138
|
+
}
|
|
@@ -39,6 +39,7 @@ export declare function testAgentNotifier(config: AgentConfig, defaultWebhook?:
|
|
|
39
39
|
/** Every decision still waiting for this agent, oldest first, each with
|
|
40
40
|
* where to send the answer. */
|
|
41
41
|
export declare function listAgentPending(store: CheckpointStore, agentName: string): Promise<({
|
|
42
|
+
decidePath: string;
|
|
42
43
|
answerPath: string;
|
|
43
44
|
pendingId: string;
|
|
44
45
|
kind: "approval" | "question";
|
|
@@ -49,6 +50,7 @@ export declare function listAgentPending(store: CheckpointStore, agentName: stri
|
|
|
49
50
|
tenant: string;
|
|
50
51
|
createdAt: string | null;
|
|
51
52
|
} | {
|
|
53
|
+
decidePath: string;
|
|
52
54
|
resolvePath: string;
|
|
53
55
|
pendingId: string;
|
|
54
56
|
kind: "approval" | "question";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { agentDirFor, createAgentEnv } from '../core/agent-env.js';
|
|
2
|
+
import { decidePath } from '../core/approval-links.js';
|
|
2
3
|
import { describeNotifier } from '../core/http-notifier.js';
|
|
3
4
|
import { sendTestSlackMessage } from '../core/http-notify-triggers/slack.js';
|
|
4
5
|
import { sendTestWebhook } from '../core/http-notify-triggers/webhook.js';
|
|
@@ -81,6 +82,8 @@ export async function listAgentPending(store, agentName) {
|
|
|
81
82
|
tenant: checkpoint.tenant,
|
|
82
83
|
createdAt: checkpoint.createdAt ?? null,
|
|
83
84
|
...(kind === 'question' ? { answerPath: `/pending-questions/${pendingId}/answer` } : { resolvePath: `/pending-approvals/${pendingId}/resolve` }),
|
|
85
|
+
// A signed link anyone can open to decide (core/approval-links.ts).
|
|
86
|
+
decidePath: decidePath(pendingId, kind),
|
|
84
87
|
});
|
|
85
88
|
}
|
|
86
89
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loopengine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.68",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
"dist/core/notifier-file.d.ts",
|
|
49
49
|
"dist/core/notifier-deliveries.js",
|
|
50
50
|
"dist/core/notifier-deliveries.d.ts",
|
|
51
|
+
"dist/core/approval-links.js",
|
|
52
|
+
"dist/core/approval-links.d.ts",
|
|
51
53
|
"dist/core/session-store.js",
|
|
52
54
|
"dist/core/session-store.d.ts",
|
|
53
55
|
"dist/core/vector-index.js",
|