@phnx-labs/agents-cli 1.22.40 → 1.22.41
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/CHANGELOG.md +22 -0
- package/README.md +5 -0
- package/dist/bin/agents +0 -0
- package/dist/bootstrap.js +0 -1
- package/dist/cli/command-registry.js +1 -2
- package/dist/commands/browser.d.ts +15 -0
- package/dist/commands/browser.js +115 -40
- package/dist/commands/feed.js +3 -11
- package/dist/commands/secrets.js +87 -97
- package/dist/commands/sessions-picker.d.ts +1 -0
- package/dist/commands/sessions-picker.js +27 -5
- package/dist/commands/sessions-share.d.ts +25 -0
- package/dist/commands/sessions-share.js +166 -0
- package/dist/commands/sessions.js +2 -0
- package/dist/commands/setup-browser.js +1 -1
- package/dist/commands/setup-preferences.js +2 -2
- package/dist/commands/webhook.js +14 -9
- package/dist/lib/browser/cdp.js +4 -0
- package/dist/lib/browser/chrome.js +12 -1
- package/dist/lib/browser/drivers/ssh.js +1 -0
- package/dist/lib/browser/profiles.d.ts +3 -3
- package/dist/lib/browser/profiles.js +7 -7
- package/dist/lib/browser/service.d.ts +24 -1
- package/dist/lib/browser/service.js +38 -14
- package/dist/lib/browser/types.d.ts +1 -1
- package/dist/lib/daemon-webhooks.d.ts +3 -1
- package/dist/lib/daemon-webhooks.js +12 -7
- package/dist/lib/device-config.js +1 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/observe-aliases.d.ts +2 -2
- package/dist/lib/observe-aliases.js +2 -11
- package/dist/lib/project-key.js +7 -0
- package/dist/lib/runner.js +21 -6
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.d.ts +1 -1
- package/dist/lib/secrets/bundles.js +1 -1
- package/dist/lib/secrets/headless.d.ts +16 -0
- package/dist/lib/secrets/headless.js +21 -0
- package/dist/lib/secrets/remote.d.ts +9 -7
- package/dist/lib/secrets/remote.js +18 -9
- package/dist/lib/session/share-html.d.ts +55 -0
- package/dist/lib/session/share-html.js +319 -0
- package/dist/lib/settings-manifest.d.ts +2 -0
- package/dist/lib/settings-manifest.js +81 -3
- package/dist/lib/share/publish.d.ts +38 -0
- package/dist/lib/share/publish.js +81 -8
- package/dist/lib/startup/command-registry.d.ts +2 -1
- package/dist/lib/startup/command-registry.js +4 -2
- package/dist/lib/triggers/handlers.d.ts +37 -2
- package/dist/lib/triggers/handlers.js +56 -5
- package/dist/lib/triggers/webhook.d.ts +80 -6
- package/dist/lib/triggers/webhook.js +127 -3
- package/dist/lib/types.d.ts +1 -1
- package/dist/lib/wrap.d.ts +33 -0
- package/dist/lib/wrap.js +70 -0
- package/package.json +1 -1
|
@@ -129,6 +129,25 @@ export declare function assertMetadataSize(customMetadata: Record<string, string
|
|
|
129
129
|
* an unhandled `TypeError: Invalid value` and crashes the publish outright.
|
|
130
130
|
*/
|
|
131
131
|
export declare function sanitizeLabel(text: string): string;
|
|
132
|
+
/**
|
|
133
|
+
* Make a free-text value safe to put in an HTTP header.
|
|
134
|
+
*
|
|
135
|
+
* `fetch` encodes header values as a **ByteString**, so any code point above 255
|
|
136
|
+
* throws `TypeError: Cannot convert argument to a ByteString` — an unhandled
|
|
137
|
+
* crash with a stack trace, mid-publish, after the body has already been read.
|
|
138
|
+
* Reproduced by publishing a session whose title ended in `…` (U+2026), and
|
|
139
|
+
* reachable by any emoji, curly quote, accented name, or CJK text in a `--label`,
|
|
140
|
+
* a `--meta` value, or a repo name.
|
|
141
|
+
*
|
|
142
|
+
* The transliterations above cover what actually shows up; anything else outside
|
|
143
|
+
* latin1 is dropped, and a value that transliterates to nothing at all (a title
|
|
144
|
+
* written entirely in a non-latin script) degrades to a marker rather than an
|
|
145
|
+
* empty header. Lossy on purpose: carrying full Unicode needs percent-encoding
|
|
146
|
+
* here AND a matching decode in the Worker, which every already-deployed Worker
|
|
147
|
+
* would render as `%E2%80%A6` until its operator ran `agents artifacts share
|
|
148
|
+
* update` — tracked as RUSH-2786.
|
|
149
|
+
*/
|
|
150
|
+
export declare function toHeaderValue(text: string): string;
|
|
132
151
|
/**
|
|
133
152
|
* Best-effort human title when `--label` is omitted: the HTML `<title>`, else a
|
|
134
153
|
* Markdown frontmatter `title:`, else the filename. Always returns something —
|
|
@@ -150,6 +169,25 @@ export interface SensitiveHit {
|
|
|
150
169
|
/** Short redacted sample so the error names *what* was found without dumping it. */
|
|
151
170
|
sample: string;
|
|
152
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Mask every email address in `text`, so a page can be published without tripping
|
|
174
|
+
* {@link scanShareContent}.
|
|
175
|
+
*
|
|
176
|
+
* Lives here, beside the scanner, rather than in `lib/redact.ts`: it exists solely
|
|
177
|
+
* to satisfy this gate, and sharing {@link EMAIL_RE} is what makes the masking
|
|
178
|
+
* *sufficient* to clear it rather than merely reducing the hit count. Two copies of
|
|
179
|
+
* the pattern in two modules would drift, and the failure mode of that drift is a
|
|
180
|
+
* refused publish at runtime.
|
|
181
|
+
*
|
|
182
|
+
* Deliberately NOT part of `redactSecrets`: an email is not a credential, and a
|
|
183
|
+
* transcript rendered for a private gist or local review reads better with the real
|
|
184
|
+
* author addresses intact. It only becomes a leak once the text is PUBLISHED — a
|
|
185
|
+
* world-readable page carrying seven of them is the RUSH-2428 incident.
|
|
186
|
+
*
|
|
187
|
+
* The whole address goes, domain included — a personal domain identifies its owner
|
|
188
|
+
* as surely as the local part does, and keeping it would buy the reader nothing.
|
|
189
|
+
*/
|
|
190
|
+
export declare function redactEmails(text: string): string;
|
|
153
191
|
/**
|
|
154
192
|
* Scan a text body for email addresses and credential-shaped strings. Returns
|
|
155
193
|
* the first few hits (deduped by kind+sample). Binary / non-text bodies yield
|
|
@@ -91,6 +91,49 @@ export function assertMetadataSize(customMetadata) {
|
|
|
91
91
|
export function sanitizeLabel(text) {
|
|
92
92
|
return text.replace(/\s+/g, ' ').trim();
|
|
93
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Typographic characters that reach a header constantly — a curly quote from a
|
|
96
|
+
* pasted prompt, an em dash from prose, the ellipsis a truncated title ends on —
|
|
97
|
+
* each mapped to the ASCII form a reader loses nothing by seeing.
|
|
98
|
+
*/
|
|
99
|
+
const HEADER_TRANSLITERATIONS = [
|
|
100
|
+
[/[‘’‚‛]/g, "'"],
|
|
101
|
+
[/[“”„‟]/g, '"'],
|
|
102
|
+
[/[–—―]/g, '-'],
|
|
103
|
+
[/…/g, '...'],
|
|
104
|
+
[/[ ]/g, ' '],
|
|
105
|
+
[/[•·]/g, '-'],
|
|
106
|
+
];
|
|
107
|
+
/**
|
|
108
|
+
* Make a free-text value safe to put in an HTTP header.
|
|
109
|
+
*
|
|
110
|
+
* `fetch` encodes header values as a **ByteString**, so any code point above 255
|
|
111
|
+
* throws `TypeError: Cannot convert argument to a ByteString` — an unhandled
|
|
112
|
+
* crash with a stack trace, mid-publish, after the body has already been read.
|
|
113
|
+
* Reproduced by publishing a session whose title ended in `…` (U+2026), and
|
|
114
|
+
* reachable by any emoji, curly quote, accented name, or CJK text in a `--label`,
|
|
115
|
+
* a `--meta` value, or a repo name.
|
|
116
|
+
*
|
|
117
|
+
* The transliterations above cover what actually shows up; anything else outside
|
|
118
|
+
* latin1 is dropped, and a value that transliterates to nothing at all (a title
|
|
119
|
+
* written entirely in a non-latin script) degrades to a marker rather than an
|
|
120
|
+
* empty header. Lossy on purpose: carrying full Unicode needs percent-encoding
|
|
121
|
+
* here AND a matching decode in the Worker, which every already-deployed Worker
|
|
122
|
+
* would render as `%E2%80%A6` until its operator ran `agents artifacts share
|
|
123
|
+
* update` — tracked as RUSH-2786.
|
|
124
|
+
*/
|
|
125
|
+
export function toHeaderValue(text) {
|
|
126
|
+
let safe = text;
|
|
127
|
+
for (const [pattern, replacement] of HEADER_TRANSLITERATIONS)
|
|
128
|
+
safe = safe.replace(pattern, replacement);
|
|
129
|
+
safe = safe.replace(/[^\x20-\x7E\xA0-\xFF]/g, '').replace(/\s+/g, ' ').trim();
|
|
130
|
+
// An input that was empty to begin with stays empty — `--meta note=` means an
|
|
131
|
+
// empty note, not an unnamed one. The marker is only for a value that HAD
|
|
132
|
+
// content and lost all of it to the latin1 fold.
|
|
133
|
+
if (safe)
|
|
134
|
+
return safe;
|
|
135
|
+
return text.trim() ? '(unnamed)' : '';
|
|
136
|
+
}
|
|
94
137
|
/**
|
|
95
138
|
* Best-effort human title when `--label` is omitted: the HTML `<title>`, else a
|
|
96
139
|
* Markdown frontmatter `title:`, else the filename. Always returns something —
|
|
@@ -141,6 +184,27 @@ export function resolveExpire(spec) {
|
|
|
141
184
|
}
|
|
142
185
|
/** Email addresses — the RUSH-2428 incident page carried seven of these. */
|
|
143
186
|
const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
|
187
|
+
/**
|
|
188
|
+
* Mask every email address in `text`, so a page can be published without tripping
|
|
189
|
+
* {@link scanShareContent}.
|
|
190
|
+
*
|
|
191
|
+
* Lives here, beside the scanner, rather than in `lib/redact.ts`: it exists solely
|
|
192
|
+
* to satisfy this gate, and sharing {@link EMAIL_RE} is what makes the masking
|
|
193
|
+
* *sufficient* to clear it rather than merely reducing the hit count. Two copies of
|
|
194
|
+
* the pattern in two modules would drift, and the failure mode of that drift is a
|
|
195
|
+
* refused publish at runtime.
|
|
196
|
+
*
|
|
197
|
+
* Deliberately NOT part of `redactSecrets`: an email is not a credential, and a
|
|
198
|
+
* transcript rendered for a private gist or local review reads better with the real
|
|
199
|
+
* author addresses intact. It only becomes a leak once the text is PUBLISHED — a
|
|
200
|
+
* world-readable page carrying seven of them is the RUSH-2428 incident.
|
|
201
|
+
*
|
|
202
|
+
* The whole address goes, domain included — a personal domain identifies its owner
|
|
203
|
+
* as surely as the local part does, and keeping it would buy the reader nothing.
|
|
204
|
+
*/
|
|
205
|
+
export function redactEmails(text) {
|
|
206
|
+
return text.replace(EMAIL_RE, '[EMAIL]');
|
|
207
|
+
}
|
|
144
208
|
/**
|
|
145
209
|
* Credential-shaped strings that an agent routinely dumps into reports: GitHub
|
|
146
210
|
* PATs, OpenAI/Anthropic/etc. API keys, AWS access keys, Slack tokens, and a
|
|
@@ -430,20 +494,29 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
|
|
|
430
494
|
h['x-share-expires-at'] = expiresAt;
|
|
431
495
|
if (unlisted)
|
|
432
496
|
h['x-share-visibility'] = 'unlisted';
|
|
497
|
+
// Every free-text header goes through toHeaderValue: a non-latin1 code point
|
|
498
|
+
// anywhere in a label, a repo name, or a --meta value throws inside fetch and
|
|
499
|
+
// crashes the publish outright.
|
|
433
500
|
if (provenance.agent)
|
|
434
|
-
h['x-share-agent'] = provenance.agent;
|
|
501
|
+
h['x-share-agent'] = toHeaderValue(provenance.agent);
|
|
435
502
|
if (provenance.session)
|
|
436
|
-
h['x-share-session'] = provenance.session;
|
|
503
|
+
h['x-share-session'] = toHeaderValue(provenance.session);
|
|
437
504
|
if (provenance.host)
|
|
438
|
-
h['x-share-host'] = provenance.host;
|
|
505
|
+
h['x-share-host'] = toHeaderValue(provenance.host);
|
|
439
506
|
if (provenance.repo)
|
|
440
|
-
h['x-share-repo'] = provenance.repo;
|
|
507
|
+
h['x-share-repo'] = toHeaderValue(provenance.repo);
|
|
441
508
|
if (provenance.date)
|
|
442
|
-
h['x-share-date'] = provenance.date;
|
|
443
|
-
h['x-share-label'] = label;
|
|
509
|
+
h['x-share-date'] = toHeaderValue(provenance.date);
|
|
510
|
+
h['x-share-label'] = toHeaderValue(label);
|
|
444
511
|
h['x-share-label-source'] = labelSource;
|
|
445
|
-
|
|
446
|
-
|
|
512
|
+
// Per VALUE, before JSON.stringify — folding the serialized form would rewrite
|
|
513
|
+
// a curly quote inside a value into a bare `"`, which is structural in JSON and
|
|
514
|
+
// makes the Worker's JSON.parse throw. It swallows that error, so every --meta
|
|
515
|
+
// key would silently vanish on a 200.
|
|
516
|
+
if (Object.keys(meta).length > 0) {
|
|
517
|
+
const headerMeta = Object.fromEntries(Object.entries(meta).map(([k, v]) => [toHeaderValue(k), toHeaderValue(v)]));
|
|
518
|
+
h['x-share-meta'] = JSON.stringify(headerMeta);
|
|
519
|
+
}
|
|
447
520
|
if (opts.noRevision)
|
|
448
521
|
h['x-share-no-revision'] = '1';
|
|
449
522
|
return h;
|
|
@@ -20,7 +20,8 @@ export declare const KNOWN_TOP_LEVEL_COMMANDS: ReadonlySet<string>;
|
|
|
20
20
|
* `set` moved under `agents models`/`agents config` (RUSH-2579); `share` moved
|
|
21
21
|
* under `agents artifacts share` (RUSH-2580). login/logout/budget/bench/mine/
|
|
22
22
|
* cost/output/profiles/snapshot/cp/resume/roster moved under nested homes
|
|
23
|
-
* (cli-surface-consolidate).
|
|
23
|
+
* (cli-surface-consolidate). `timeline` was removed as a duplicated surface —
|
|
24
|
+
* use `agents feed --filter updates` (RUSH-2692).
|
|
24
25
|
*/
|
|
25
26
|
export declare const RETIRED_TOP_LEVEL_COMMANDS: ReadonlySet<string>;
|
|
26
27
|
export declare function isKnownTopLevelCommand(name: string): boolean;
|
|
@@ -9,7 +9,7 @@ const LOADED_COMMAND_NAMES = [
|
|
|
9
9
|
'alias', 'pty', 'tmux', 'watchdog', 'browser', 'computer', 'logs', 'events',
|
|
10
10
|
'ssh', 'devices', 'fleet', 'repos', 'repo', 'setup', 'uninstall', 'upgrade', 'sessions',
|
|
11
11
|
'teams', 'tickets', 'cloud', 'message', 'send', 'notify', 'feed', 'inbox',
|
|
12
|
-
'
|
|
12
|
+
'mailboxes', 'mailbox', 'serve', 'artifacts', 'unshare', 'audit', 'webhooks',
|
|
13
13
|
'humans', 'daemon',
|
|
14
14
|
];
|
|
15
15
|
const INLINE_COMMAND_NAMES = [
|
|
@@ -40,7 +40,8 @@ export const KNOWN_TOP_LEVEL_COMMANDS = new Set([
|
|
|
40
40
|
* `set` moved under `agents models`/`agents config` (RUSH-2579); `share` moved
|
|
41
41
|
* under `agents artifacts share` (RUSH-2580). login/logout/budget/bench/mine/
|
|
42
42
|
* cost/output/profiles/snapshot/cp/resume/roster moved under nested homes
|
|
43
|
-
* (cli-surface-consolidate).
|
|
43
|
+
* (cli-surface-consolidate). `timeline` was removed as a duplicated surface —
|
|
44
|
+
* use `agents feed --filter updates` (RUSH-2692).
|
|
44
45
|
*/
|
|
45
46
|
export const RETIRED_TOP_LEVEL_COMMANDS = new Set([
|
|
46
47
|
'webhook',
|
|
@@ -58,6 +59,7 @@ export const RETIRED_TOP_LEVEL_COMMANDS = new Set([
|
|
|
58
59
|
'roster',
|
|
59
60
|
'set',
|
|
60
61
|
'share',
|
|
62
|
+
'timeline',
|
|
61
63
|
]);
|
|
62
64
|
export function isKnownTopLevelCommand(name) {
|
|
63
65
|
return KNOWN_TOP_LEVEL_COMMANDS.has(name);
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { JobConfig, RunMeta, WebhookContext } from '../scheduling/routines.js';
|
|
9
9
|
import type { AgentId } from '../types.js';
|
|
10
|
-
import type { IncomingWebhook, WebhookSource } from './webhook.js';
|
|
10
|
+
import type { IncomingWebhook, SlackPayload, WebhookSource } from './webhook.js';
|
|
11
11
|
export interface WebhookHandler {
|
|
12
12
|
name: string;
|
|
13
13
|
enabled?: boolean;
|
|
@@ -21,6 +21,11 @@ export interface WebhookHandler {
|
|
|
21
21
|
label?: string;
|
|
22
22
|
repo?: string;
|
|
23
23
|
branch?: string;
|
|
24
|
+
/** Slack (source: slack) only — match one slash command, e.g. `/agents`. */
|
|
25
|
+
command?: string;
|
|
26
|
+
/** Slack (source: slack) only — restrict to one channel id (`C0…`). Lets a
|
|
27
|
+
* channel imply a project via a per-channel handler. */
|
|
28
|
+
channel?: string;
|
|
24
29
|
/**
|
|
25
30
|
* Where to run the action. A device name (`yosemite-s0`) runs there over SSH;
|
|
26
31
|
* `fleet` picks any eligible online worker; `fleet/<platform>` (or
|
|
@@ -92,10 +97,40 @@ export declare function resolveHandlerHost(host: string | undefined): HandlerHos
|
|
|
92
97
|
export declare function listHandlers(cwd?: string): WebhookHandler[];
|
|
93
98
|
/** Pure matcher: does this handler match the incoming webhook? */
|
|
94
99
|
export declare function handlerMatchesWebhook(handler: WebhookHandler, webhook: IncomingWebhook): boolean;
|
|
100
|
+
/**
|
|
101
|
+
* The `{{slack.*}}` substitution namespace: a Slack message split into an
|
|
102
|
+
* agent-actionable project + prompt, plus the coordinates a reply threads into.
|
|
103
|
+
*/
|
|
104
|
+
export interface SlackMessageContext {
|
|
105
|
+
/** The message with a leading bot mention stripped. */
|
|
106
|
+
text: string;
|
|
107
|
+
/** The `PROJECT:` token at the head of the message, or '' when absent. */
|
|
108
|
+
project: string;
|
|
109
|
+
/** The request — the text after `PROJECT:`, or the whole message. */
|
|
110
|
+
prompt: string;
|
|
111
|
+
/** Channel id to reply into (`C0…`). */
|
|
112
|
+
channel: string;
|
|
113
|
+
/** Thread ts to reply into (empty for a slash command → reply to the channel). */
|
|
114
|
+
thread_ts: string;
|
|
115
|
+
/** Invoking user id (`U0…`). */
|
|
116
|
+
user: string;
|
|
117
|
+
/** Slash command name (`/agents`), or '' for an event delivery. */
|
|
118
|
+
command: string;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Split a Slack message into a `{{slack.*}}` context. Strips a leading bot
|
|
122
|
+
* mention (`<@U0BOT> …`), then reads an optional `PROJECT:` prefix — a single
|
|
123
|
+
* bare token followed by a colon and a space — as the project, with the
|
|
124
|
+
* remainder the prompt. `AGI: rebase my PR` → project `AGI`, prompt
|
|
125
|
+
* `rebase my PR`; a message with no such prefix leaves `project` empty and the
|
|
126
|
+
* whole text as the prompt. The token pattern forbids slashes, so a project
|
|
127
|
+
* value can never carry a path into a `cwd`/`project` substitution.
|
|
128
|
+
*/
|
|
129
|
+
export declare function parseSlackMessage(payload: SlackPayload): SlackMessageContext;
|
|
95
130
|
/**
|
|
96
131
|
* Build the variable-substitution context for a webhook. Linear events expose
|
|
97
132
|
* `issue` and `updatedFrom`; GitHub events expose `repository`, `pull_request`,
|
|
98
|
-
* and `issue
|
|
133
|
+
* and `issue`; Slack events expose the `{{slack.*}}` namespace above.
|
|
99
134
|
*/
|
|
100
135
|
export declare function buildWebhookContext(webhook: IncomingWebhook): WebhookContext;
|
|
101
136
|
export interface ExecuteHandlerOptions {
|
|
@@ -208,6 +208,17 @@ export function handlerMatchesWebhook(handler, webhook) {
|
|
|
208
208
|
return false;
|
|
209
209
|
if (!handlerRunsOnThisDevice(handler))
|
|
210
210
|
return false;
|
|
211
|
+
// Slack has no GitHub/Linear action/label/branch vocabulary; it matches on the
|
|
212
|
+
// slash command and (optionally) the channel. `event` already pinned the
|
|
213
|
+
// subtype (`app_mention`) or the slash command name above.
|
|
214
|
+
if (webhook.source === 'slack') {
|
|
215
|
+
const slack = webhook.payload;
|
|
216
|
+
if (handler.command && slack.command !== handler.command)
|
|
217
|
+
return false;
|
|
218
|
+
if (handler.channel && slack.channel !== handler.channel)
|
|
219
|
+
return false;
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
211
222
|
if (handler.action) {
|
|
212
223
|
const action = webhook.source === 'github' ? githubAction(webhook.payload) : linearAction(webhook.payload);
|
|
213
224
|
if (action !== handler.action)
|
|
@@ -262,12 +273,47 @@ export function handlerMatchesWebhook(handler, webhook) {
|
|
|
262
273
|
}
|
|
263
274
|
return true;
|
|
264
275
|
}
|
|
276
|
+
const asString = (v) => (typeof v === 'string' ? v : '');
|
|
277
|
+
/**
|
|
278
|
+
* Split a Slack message into a `{{slack.*}}` context. Strips a leading bot
|
|
279
|
+
* mention (`<@U0BOT> …`), then reads an optional `PROJECT:` prefix — a single
|
|
280
|
+
* bare token followed by a colon and a space — as the project, with the
|
|
281
|
+
* remainder the prompt. `AGI: rebase my PR` → project `AGI`, prompt
|
|
282
|
+
* `rebase my PR`; a message with no such prefix leaves `project` empty and the
|
|
283
|
+
* whole text as the prompt. The token pattern forbids slashes, so a project
|
|
284
|
+
* value can never carry a path into a `cwd`/`project` substitution.
|
|
285
|
+
*/
|
|
286
|
+
export function parseSlackMessage(payload) {
|
|
287
|
+
const cleaned = asString(payload.text).replace(/^\s*<@[^>]+>\s*/, '').trim();
|
|
288
|
+
const m = /^([A-Za-z0-9][\w.-]*)\s*:\s+([\s\S]+)$/.exec(cleaned);
|
|
289
|
+
return {
|
|
290
|
+
text: cleaned,
|
|
291
|
+
project: m ? m[1] : '',
|
|
292
|
+
prompt: m ? m[2].trim() : cleaned,
|
|
293
|
+
channel: asString(payload.channel),
|
|
294
|
+
thread_ts: asString(payload.thread_ts),
|
|
295
|
+
user: asString(payload.user),
|
|
296
|
+
command: asString(payload.command),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
265
299
|
/**
|
|
266
300
|
* Build the variable-substitution context for a webhook. Linear events expose
|
|
267
301
|
* `issue` and `updatedFrom`; GitHub events expose `repository`, `pull_request`,
|
|
268
|
-
* and `issue
|
|
302
|
+
* and `issue`; Slack events expose the `{{slack.*}}` namespace above.
|
|
269
303
|
*/
|
|
270
304
|
export function buildWebhookContext(webhook) {
|
|
305
|
+
if (webhook.source === 'slack') {
|
|
306
|
+
// The `{{slack.*}}` namespace rides an intersection over WebhookContext (a
|
|
307
|
+
// subtype of it) rather than a field on the shared type, so a Slack-only
|
|
308
|
+
// addition never touches the scheduling hub every routine test depends on.
|
|
309
|
+
// `substituteWebhookPrompt`/`getPath` read it dynamically at runtime.
|
|
310
|
+
const ctx = {
|
|
311
|
+
source: webhook.source,
|
|
312
|
+
event: webhook.event,
|
|
313
|
+
slack: parseSlackMessage(webhook.payload),
|
|
314
|
+
};
|
|
315
|
+
return ctx;
|
|
316
|
+
}
|
|
271
317
|
const action = webhook.source === 'github'
|
|
272
318
|
? githubAction(webhook.payload) ?? undefined
|
|
273
319
|
: linearAction(webhook.payload) ?? undefined;
|
|
@@ -346,6 +392,11 @@ export async function executeHandler(handler, webhook, opts = {}) {
|
|
|
346
392
|
}
|
|
347
393
|
async function executeHandlerAction(handler, webhook, context, opts) {
|
|
348
394
|
const substitutedPrompt = handler.run?.prompt ? substituteWebhookPrompt(handler.run.prompt, context) : '';
|
|
395
|
+
// Slack routes the project per-message (`AGI: …`), so a handler may template
|
|
396
|
+
// its project/cwd — e.g. `project: "{{slack.project}}"`. Substitute + trim; an
|
|
397
|
+
// empty result omits the field (falls back to the run's default cwd / $HOME).
|
|
398
|
+
const substitutedProject = handler.project ? substituteWebhookPrompt(handler.project, context).trim() : '';
|
|
399
|
+
const substitutedCwd = handler.cwd ? substituteWebhookPrompt(handler.cwd, context).trim() : '';
|
|
349
400
|
// Resolved once so a fleet pick can't differ between the two dispatch paths.
|
|
350
401
|
const hostFields = resolveHandlerHost(handler.host);
|
|
351
402
|
if (handler.run?.agent || handler.run?.workflow) {
|
|
@@ -359,8 +410,8 @@ async function executeHandlerAction(handler, webhook, context, opts) {
|
|
|
359
410
|
...(handler.run.agent ? { agent: handler.run.agent } : { workflow: handler.run.workflow }),
|
|
360
411
|
...(handler.devices ? { devices: handler.devices } : {}),
|
|
361
412
|
...(handler.run.env ? { env: handler.run.env } : {}),
|
|
362
|
-
...(
|
|
363
|
-
...(
|
|
413
|
+
...(substitutedProject ? { project: substitutedProject } : {}),
|
|
414
|
+
...(substitutedCwd ? { cwd: substitutedCwd } : {}),
|
|
364
415
|
...(hostFields.host ? { host: hostFields.host } : {}),
|
|
365
416
|
...(hostFields.hostStrategy ? { hostStrategy: hostFields.hostStrategy } : {}),
|
|
366
417
|
};
|
|
@@ -385,8 +436,8 @@ async function executeHandlerAction(handler, webhook, context, opts) {
|
|
|
385
436
|
...routine,
|
|
386
437
|
prompt: substituteWebhookPrompt(routine.prompt, context),
|
|
387
438
|
...(handler.devices ? { devices: handler.devices } : {}),
|
|
388
|
-
...(
|
|
389
|
-
...(
|
|
439
|
+
...(substitutedProject ? { project: substitutedProject } : {}),
|
|
440
|
+
...(substitutedCwd ? { cwd: substitutedCwd } : {}),
|
|
390
441
|
...(handler.mode ? { mode: handler.mode } : {}),
|
|
391
442
|
...(hostFields.host ? { host: hostFields.host } : {}),
|
|
392
443
|
...(hostFields.hostStrategy ? { hostStrategy: hostFields.hostStrategy } : {}),
|
|
@@ -15,13 +15,15 @@ import * as http from 'http';
|
|
|
15
15
|
import type { IncomingHttpHeaders } from 'http';
|
|
16
16
|
import type { JobConfig, RunMeta, WebhookContext } from '../scheduling/routines.js';
|
|
17
17
|
import { type FiredHandler } from './handlers.js';
|
|
18
|
-
export type WebhookSource = 'github' | 'linear';
|
|
18
|
+
export type WebhookSource = 'github' | 'linear' | 'slack';
|
|
19
19
|
export interface IncomingWebhook {
|
|
20
20
|
/** Delivery source, derived from `/hooks/<source>` or one-shot command flags. */
|
|
21
21
|
source: WebhookSource;
|
|
22
|
-
/** Source event name: GitHub header event
|
|
22
|
+
/** Source event name: GitHub header event, Linear payload `type`, or the Slack
|
|
23
|
+
* event subtype (`app_mention`) / slash command (`/agents`). */
|
|
23
24
|
event: string;
|
|
24
|
-
/** Decoded
|
|
25
|
+
/** Decoded request body (JSON for GitHub/Linear/Slack-events; the normalized
|
|
26
|
+
* {@link SlackPayload} for a Slack slash command's form body). */
|
|
25
27
|
payload: Record<string, unknown>;
|
|
26
28
|
}
|
|
27
29
|
export type GithubWebhook = IncomingWebhook & {
|
|
@@ -30,6 +32,9 @@ export type GithubWebhook = IncomingWebhook & {
|
|
|
30
32
|
export type LinearWebhook = IncomingWebhook & {
|
|
31
33
|
source: 'linear';
|
|
32
34
|
};
|
|
35
|
+
export type SlackWebhook = IncomingWebhook & {
|
|
36
|
+
source: 'slack';
|
|
37
|
+
};
|
|
33
38
|
/** Read `repository.full_name` (`owner/name`) from a webhook payload, if present. */
|
|
34
39
|
export declare function webhookRepo(payload: Record<string, unknown>): string | null;
|
|
35
40
|
/**
|
|
@@ -96,9 +101,59 @@ export declare function fireWebhookJobs(webhook: IncomingWebhook, options?: Fire
|
|
|
96
101
|
export declare function verifyGithubSignature(headers: IncomingHttpHeaders, rawBody: Buffer, secret: string): boolean;
|
|
97
102
|
export declare function verifyLinearSignature(headers: IncomingHttpHeaders, rawBody: Buffer, secret: string): boolean;
|
|
98
103
|
export declare function verifyLinearTimestamp(payload: Record<string, unknown>, now?: number, toleranceMs?: number): boolean;
|
|
104
|
+
/**
|
|
105
|
+
* Verify a Slack request signature (the `v0` scheme). Slack signs the base
|
|
106
|
+
* string `v0:${timestamp}:${rawBody}` with the app's signing secret and sends
|
|
107
|
+
* the hex digest as `X-Slack-Signature: v0=<hex>`, alongside the unix
|
|
108
|
+
* `X-Slack-Request-Timestamp`. The timestamp is BOTH part of the signed base
|
|
109
|
+
* string AND checked for freshness here — a request older than `toleranceSec`
|
|
110
|
+
* (default 5 min) is rejected, so a captured, correctly-signed body cannot be
|
|
111
|
+
* replayed later. Fails closed on any missing/malformed header.
|
|
112
|
+
*/
|
|
113
|
+
export declare function verifySlackSignature(headers: IncomingHttpHeaders, rawBody: Buffer, secret: string, now?: number, toleranceSec?: number): boolean;
|
|
114
|
+
/**
|
|
115
|
+
* Normalized fields extracted from a Slack slash-command or Events API delivery.
|
|
116
|
+
* This is the `payload` of a Slack {@link IncomingWebhook}: transport-level
|
|
117
|
+
* parsing lives here, message *semantics* (splitting the mention text into an
|
|
118
|
+
* agent / project / prompt) live in `handlers.ts` `buildWebhookContext`.
|
|
119
|
+
*/
|
|
120
|
+
export interface SlackPayload extends Record<string, unknown> {
|
|
121
|
+
/** `url_verification` | `event_callback` | `slash_command`. */
|
|
122
|
+
type: string;
|
|
123
|
+
/** Echoed back for the one-time Events API URL-verification handshake. */
|
|
124
|
+
challenge?: string;
|
|
125
|
+
/** Slack event id (Events API) — the dedup key when present. */
|
|
126
|
+
event_id?: string;
|
|
127
|
+
/** Event subtype, e.g. `app_mention` (Events API). */
|
|
128
|
+
event_type?: string;
|
|
129
|
+
/** Raw message text (the mention body, or the slash-command text). */
|
|
130
|
+
text?: string;
|
|
131
|
+
/** Channel id (`C0…`) the message arrived in. */
|
|
132
|
+
channel?: string;
|
|
133
|
+
/** Thread to reply into: an existing thread's parent ts, else the message ts. */
|
|
134
|
+
thread_ts?: string;
|
|
135
|
+
/** Invoking user id (`U0…`). */
|
|
136
|
+
user?: string;
|
|
137
|
+
/** Slash command name, e.g. `/agents`. */
|
|
138
|
+
command?: string;
|
|
139
|
+
/** Slash-command `response_url` (valid ~30 min, 5 uses). */
|
|
140
|
+
response_url?: string;
|
|
141
|
+
/** Slack team / workspace id. */
|
|
142
|
+
team?: string;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Parse a Slack delivery body into a normalized {@link SlackPayload}. Slack
|
|
146
|
+
* sends slash commands as `application/x-www-form-urlencoded` and Events API
|
|
147
|
+
* deliveries (including the `url_verification` handshake) as
|
|
148
|
+
* `application/json` — this is the one place that content-type branch lives.
|
|
149
|
+
*/
|
|
150
|
+
export declare function parseSlackBody(contentType: string | undefined, rawBody: Buffer): SlackPayload;
|
|
151
|
+
/** The `event` name a Slack delivery fires under: the slash command, or the event subtype. */
|
|
152
|
+
export declare function slackEventName(payload: SlackPayload): string;
|
|
99
153
|
export interface WebhookSecrets {
|
|
100
154
|
github?: string;
|
|
101
155
|
linear?: string;
|
|
156
|
+
slack?: string;
|
|
102
157
|
}
|
|
103
158
|
export interface DeliveryStore {
|
|
104
159
|
seen(id: string): boolean;
|
|
@@ -140,11 +195,25 @@ export interface WebhookServerOptions {
|
|
|
140
195
|
secrets: WebhookSecrets;
|
|
141
196
|
/** Override the fire options (mainly for tests). */
|
|
142
197
|
fire?: FireWebhookOptions;
|
|
198
|
+
/**
|
|
199
|
+
* Called the moment a delivery's matches are known — BEFORE any routine or
|
|
200
|
+
* handler is dispatched. Matching is a pure, synchronous lookup (no I/O), so
|
|
201
|
+
* this fires immediately after the ack, regardless of how long the matched
|
|
202
|
+
* work then takes to run. This is the right hook for a "webhook X fired Y"
|
|
203
|
+
* log line: a `run.command` handler that shells out a long agent session
|
|
204
|
+
* (`exec()`, which only resolves on process exit) used to hold that log back
|
|
205
|
+
* for as long as the session ran — `ps` would show the agent already running
|
|
206
|
+
* while the log still implied nothing had matched (RUSH-2722). Names only
|
|
207
|
+
* (no runId/exitCode yet — those aren't known until dispatch settles).
|
|
208
|
+
*/
|
|
209
|
+
onMatch?: (webhook: IncomingWebhook, matchedJobNames: string[], matchedHandlerNames: string[]) => void;
|
|
143
210
|
/**
|
|
144
211
|
* Called after a delivery has fully settled — every matched routine and
|
|
145
|
-
* handler dispatched
|
|
146
|
-
* dispatch (see `startWebhookServer`),
|
|
147
|
-
* has been written, and is the only
|
|
212
|
+
* handler dispatched (and, for `run.command` handlers, exited). Because the
|
|
213
|
+
* receiver acks the HTTP response BEFORE dispatch (see `startWebhookServer`),
|
|
214
|
+
* this fires strictly after the response has been written, and is the only
|
|
215
|
+
* way a caller observes the dispatch outcome (runId/exitCode/output). It is
|
|
216
|
+
* NOT the right hook for "did this webhook match" logging — use `onMatch`.
|
|
148
217
|
*/
|
|
149
218
|
onDelivery?: (webhook: IncomingWebhook, fired: FiredJob[], handlers: FiredHandler[]) => void;
|
|
150
219
|
/**
|
|
@@ -183,6 +252,11 @@ export interface WebhookServerOptions {
|
|
|
183
252
|
* duplicate from an in-flight set, since `deliveryStore.seen` only reports
|
|
184
253
|
* completed deliveries and would otherwise let a mid-flight retry double-fire.
|
|
185
254
|
*
|
|
255
|
+
* `onMatch` fires as soon as matching is known, before any dispatch; `onDelivery`
|
|
256
|
+
* fires only once every matched routine/handler has settled — see their docs on
|
|
257
|
+
* `WebhookServerOptions`. A caller that wants a prompt "this fired" log uses
|
|
258
|
+
* `onMatch`, not `onDelivery` (RUSH-2722).
|
|
259
|
+
*
|
|
186
260
|
* Returns the underlying server so callers can `close()` it.
|
|
187
261
|
*/
|
|
188
262
|
export declare function startWebhookServer(options: WebhookServerOptions): http.Server;
|