@open-everywhere/provider-slack 0.1.5 → 0.2.1
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/NOTICE +9 -9
- package/README.md +1 -1
- package/agent/app-manifest.ts +44 -9
- package/agent/channels/slack-commands.ts +9 -5
- package/agent/channels/slack-events.ts +73 -13
- package/agent/channels/slack-front.ts +2 -2
- package/agent/channels/slack-task-card.ts +182 -0
- package/agent/channels/slack.ts +518 -468
- package/agent/contributions.ts +1 -1
- package/agent/docs-generated.ts +1 -1
- package/agent/ops.ts +20 -2
- package/agent/setup/eve-credentials.ts +10 -2
- package/agent/setup/page.ts +12 -9
- package/dist/agent/app-manifest.d.ts +20 -4
- package/dist/agent/app-manifest.d.ts.map +1 -1
- package/dist/agent/app-manifest.js +29 -6
- package/dist/agent/app-manifest.js.map +1 -1
- package/dist/agent/channels/slack-commands.d.ts.map +1 -1
- package/dist/agent/channels/slack-commands.js +9 -6
- package/dist/agent/channels/slack-commands.js.map +1 -1
- package/dist/agent/channels/slack-events.d.ts +28 -6
- package/dist/agent/channels/slack-events.d.ts.map +1 -1
- package/dist/agent/channels/slack-events.js +71 -14
- package/dist/agent/channels/slack-events.js.map +1 -1
- package/dist/agent/channels/slack-front.d.ts +1 -1
- package/dist/agent/channels/slack-front.js +1 -1
- package/dist/agent/channels/slack-front.js.map +1 -1
- package/dist/agent/channels/slack-task-card.d.ts +38 -0
- package/dist/agent/channels/slack-task-card.d.ts.map +1 -0
- package/dist/agent/channels/slack-task-card.js +156 -0
- package/dist/agent/channels/slack-task-card.js.map +1 -0
- package/dist/agent/channels/slack.d.ts.map +1 -1
- package/dist/agent/channels/slack.js +516 -466
- package/dist/agent/channels/slack.js.map +1 -1
- package/dist/agent/contributions.d.ts +1 -1
- package/dist/agent/contributions.d.ts.map +1 -1
- package/dist/agent/contributions.js +1 -1
- package/dist/agent/contributions.js.map +1 -1
- package/dist/agent/docs-generated.js +1 -1
- package/dist/agent/docs-generated.js.map +1 -1
- package/dist/agent/ops.d.ts +5 -0
- package/dist/agent/ops.d.ts.map +1 -1
- package/dist/agent/ops.js +13 -2
- package/dist/agent/ops.js.map +1 -1
- package/dist/agent/setup/eve-credentials.d.ts +9 -2
- package/dist/agent/setup/eve-credentials.d.ts.map +1 -1
- package/dist/agent/setup/eve-credentials.js +9 -1
- package/dist/agent/setup/eve-credentials.js.map +1 -1
- package/dist/agent/setup/page.d.ts.map +1 -1
- package/dist/agent/setup/page.js +12 -10
- package/dist/agent/setup/page.js.map +1 -1
- package/package.json +10 -3
package/NOTICE
CHANGED
|
@@ -12,28 +12,28 @@ Apache License, Version 2.0.
|
|
|
12
12
|
|
|
13
13
|
Modifications to eve
|
|
14
14
|
|
|
15
|
-
The @open-everywhere/core package distributes patches/eve+0.
|
|
16
|
-
patch file is a modification of eve 0.
|
|
17
|
-
Apache License, Version 2.0. The copyright notice of eve 0.
|
|
15
|
+
The @open-everywhere/core package distributes patches/eve+0.71.0.patch. That
|
|
16
|
+
patch file is a modification of eve 0.71.0. Vercel licenses eve 0.71.0 under the
|
|
17
|
+
Apache License, Version 2.0. The copyright notice of eve 0.71.0 reads
|
|
18
18
|
"Copyright 2026 Vercel, Inc. and contributors". The Open Everywhere authors made
|
|
19
19
|
the changes in the patch file.
|
|
20
20
|
|
|
21
|
-
The patch modifies
|
|
21
|
+
The patch modifies four files of eve 0.71.0:
|
|
22
22
|
|
|
23
|
-
dist/src/context/dynamic-tool-lifecycle.js
|
|
24
23
|
dist/src/execution/web-fetch/request.js
|
|
25
24
|
dist/src/harness/tool-loop.js
|
|
26
25
|
dist/src/internal/authored-package-boundary.js
|
|
27
26
|
dist/src/protocol/message.d.ts
|
|
28
|
-
dist/src/tasks/session-index.js
|
|
29
27
|
|
|
30
28
|
Open Everywhere does not ship the eve package. eve is a peer dependency, so the
|
|
31
|
-
consumer installs eve 0.
|
|
29
|
+
consumer installs eve 0.71.0 from the registry. The patch file carries the
|
|
32
30
|
modified portions of eve. The oe-eve-patch command, which
|
|
33
31
|
@open-everywhere/core ships, applies them to the eve that the consumer
|
|
34
|
-
installed. The command
|
|
32
|
+
installed. The command patches no file outside the consumer's own project. To
|
|
33
|
+
restore an eve that holds an older Open Everywhere patch, it also writes to npm's
|
|
34
|
+
cache and to a temporary directory.
|
|
35
35
|
|
|
36
|
-
The attribution notice of eve 0.
|
|
36
|
+
The attribution notice of eve 0.71.0 follows, as section 4(d) of the Apache
|
|
37
37
|
License, Version 2.0 requires. The notices for eve's bundled code-mode runtime
|
|
38
38
|
are not reproduced, because the patch modifies no part of that runtime.
|
|
39
39
|
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ asserts that **every** one of them is absent from `packages/core/agent`. For Sla
|
|
|
15
15
|
## Install
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
|
-
npm i @open-everywhere/core @open-everywhere/provider-slack eve@0.
|
|
18
|
+
npm i @open-everywhere/core @open-everywhere/provider-slack eve@0.71.0
|
|
19
19
|
npx oe-eve-patch
|
|
20
20
|
```
|
|
21
21
|
|
package/agent/app-manifest.ts
CHANGED
|
@@ -16,9 +16,16 @@
|
|
|
16
16
|
* operator, who has usually not deployed yet when they run it. */
|
|
17
17
|
export const SLACK_DEPLOY_HOST_PLACEHOLDER = "YOUR-DEPLOY-URL";
|
|
18
18
|
|
|
19
|
-
/** The app name the manifest carries when the operator names none
|
|
19
|
+
/** The app name the manifest carries when the operator names none AND the deployment configures no
|
|
20
|
+
* `identity.name`. The same brand default core's `agentDisplayName()` falls back to. */
|
|
20
21
|
export const SLACK_DEFAULT_APP_NAME = "Open Everywhere";
|
|
21
22
|
|
|
23
|
+
/** The slash command's name when the caller passes none — the shipped `identity.commandName`. */
|
|
24
|
+
const SLACK_DEFAULT_COMMAND_NAME = "oe";
|
|
25
|
+
|
|
26
|
+
/** Slack's limit on a shortcut's name. A longer one fails at paste time. */
|
|
27
|
+
export const SLACK_SHORTCUT_NAME_MAX = 24;
|
|
28
|
+
|
|
22
29
|
/** Slack's limit on an app name (`display_information.name`). A longer name fails at paste time. */
|
|
23
30
|
export const SLACK_APP_NAME_MAX = 35;
|
|
24
31
|
|
|
@@ -31,17 +38,44 @@ export function slackAppNameProblem(raw: string): string | undefined {
|
|
|
31
38
|
return undefined;
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
/** Options for {@link slackAppManifestTemplate}.
|
|
41
|
+
/** Options for {@link slackAppManifestTemplate}. This module imports nothing, so the caller reads
|
|
42
|
+
* the deployment's settings and passes them in (OPE-2334): `agent/ops.ts` and the `/setup` page
|
|
43
|
+
* read core's resolvers, and the setup CLI reads the app's own settings file. */
|
|
35
44
|
export interface SlackAppManifestOptions {
|
|
36
|
-
/**
|
|
37
|
-
* second install in one workspace needs its own name, or the two apps
|
|
45
|
+
/** An EXPLICIT app name (`--app-name`, the ops tool's `appName`, the `/setup` form). It wins
|
|
46
|
+
* over {@link agentName}. A second install in one workspace needs its own name, or the two apps
|
|
47
|
+
* look the same. */
|
|
38
48
|
readonly appName?: string;
|
|
49
|
+
/** The deployment's configured name — the `identity.name` setting. It names the app, the bot user,
|
|
50
|
+
* the shortcuts and the command description when no explicit {@link appName} is given. Absent ⇒
|
|
51
|
+
* {@link SLACK_DEFAULT_APP_NAME}. */
|
|
52
|
+
readonly agentName?: string;
|
|
53
|
+
/** The slash command's name without the slash — the `identity.commandName` setting. Absent ⇒
|
|
54
|
+
* {@link SLACK_DEFAULT_COMMAND_NAME}. */
|
|
55
|
+
readonly commandName?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The app name a manifest carries: the explicit name, else the configured name, else the default.
|
|
59
|
+
* A configured name past Slack's limit is cut to fit, because Slack refuses the whole paste. */
|
|
60
|
+
export function slackManifestAppName(opts: SlackAppManifestOptions = {}): string {
|
|
61
|
+
const explicit = opts.appName?.trim();
|
|
62
|
+
if (explicit) return explicit;
|
|
63
|
+
const configured = opts.agentName?.trim();
|
|
64
|
+
if (configured) return configured.slice(0, SLACK_APP_NAME_MAX).trim();
|
|
65
|
+
return SLACK_DEFAULT_APP_NAME;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A shortcut name that names the app, or the neutral form when the name does not fit. */
|
|
69
|
+
function shortcutName(prefix: string, appName: string, neutral: string): string {
|
|
70
|
+
const named = `${prefix} ${appName}`;
|
|
71
|
+
return named.length <= SLACK_SHORTCUT_NAME_MAX ? named : neutral;
|
|
39
72
|
}
|
|
40
73
|
|
|
41
74
|
/** A ready-to-paste Slack app manifest (Slack's "create from manifest" flow) with the scope/event
|
|
42
75
|
* set docs/deploy/surfaces/slack.md walks through by hand. */
|
|
43
76
|
export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): string {
|
|
44
|
-
const appName = opts
|
|
77
|
+
const appName = slackManifestAppName(opts);
|
|
78
|
+
const commandName = opts.commandName?.trim() || SLACK_DEFAULT_COMMAND_NAME;
|
|
45
79
|
return JSON.stringify(
|
|
46
80
|
{
|
|
47
81
|
display_information: { name: appName },
|
|
@@ -60,9 +94,10 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
|
|
|
60
94
|
// configures SEPARATELY from the Events request URL above.
|
|
61
95
|
slash_commands: [
|
|
62
96
|
{
|
|
63
|
-
|
|
97
|
+
// The `identity.commandName` setting (OPE-2334). The route checks the same name.
|
|
98
|
+
command: `/${commandName}`,
|
|
64
99
|
url: `https://${SLACK_DEPLOY_HOST_PLACEHOLDER}/eve/v1/slack/commands`,
|
|
65
|
-
description:
|
|
100
|
+
description: `${appName} control-plane commands (stop, status, budget) — no model turn.`,
|
|
66
101
|
// Every subcommand `parseOeCommandTokens` accepts (packages/core/agent/commands/core.ts).
|
|
67
102
|
// scripts/slack-app-manifest-test.ts fails if one is missing here.
|
|
68
103
|
usage_hint: "stop | status | budget [raise <usd> | clear] | rate <n> | help",
|
|
@@ -80,13 +115,13 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
|
|
|
80
115
|
// receives, so renaming one here changes only the trigger name the model is told about.
|
|
81
116
|
shortcuts: [
|
|
82
117
|
{
|
|
83
|
-
name: "Send to
|
|
118
|
+
name: shortcutName("Send to", appName, "Send to the agent"),
|
|
84
119
|
type: "message",
|
|
85
120
|
callback_id: "oe_message_shortcut",
|
|
86
121
|
description: "Wake the agent on this message.",
|
|
87
122
|
},
|
|
88
123
|
{
|
|
89
|
-
name: "Ask
|
|
124
|
+
name: shortcutName("Ask", appName, "Ask the agent"),
|
|
90
125
|
type: "global",
|
|
91
126
|
callback_id: "oe_global_shortcut",
|
|
92
127
|
description: "Wake the agent in a DM.",
|
|
@@ -38,6 +38,7 @@ import { slackSigningSecret } from "../setup/credentials.js";
|
|
|
38
38
|
import {
|
|
39
39
|
bindFrom,
|
|
40
40
|
runOeCommand,
|
|
41
|
+
oeSlash,
|
|
41
42
|
parseSlackCommandText,
|
|
42
43
|
resolvePartition,
|
|
43
44
|
getOperationalStore,
|
|
@@ -204,13 +205,16 @@ export default defineChannel({
|
|
|
204
205
|
}
|
|
205
206
|
|
|
206
207
|
const command = formField(params, "command");
|
|
207
|
-
|
|
208
|
-
|
|
208
|
+
// The command's name is the `identity.commandName` setting (OPE-2334), the same value the
|
|
209
|
+
// manifest declares, so a renamed command is answered here rather than refused.
|
|
210
|
+
const expected = oeSlash();
|
|
211
|
+
if (command !== expected) {
|
|
212
|
+
// Defensive, not a security gate: this route is dedicated to the one command — the Slack app's
|
|
209
213
|
// Slash Command config should only ever point ONE command at it. Anything else is a
|
|
210
214
|
// misconfiguration (a second command aimed at the same URL); answer politely instead
|
|
211
215
|
// of a bare 404/500 so it's obvious in Slack what to fix.
|
|
212
|
-
log(`unexpected slash command "${command ?? "?"}" hit the
|
|
213
|
-
return ephemeralResponse(
|
|
216
|
+
log(`unexpected slash command "${command ?? "?"}" hit the ${expected} route — check the Slack app's Slash Command config`);
|
|
217
|
+
return ephemeralResponse(`This endpoint only handles "${expected}" — check your Slack app's Slash Command configuration.`);
|
|
214
218
|
}
|
|
215
219
|
|
|
216
220
|
const cmd = parseSlackCommandText(formField(params, "text"));
|
|
@@ -223,7 +227,7 @@ export default defineChannel({
|
|
|
223
227
|
// (e.g. proposing from a DM with no explicit scope) — surface it verbatim. Never let a
|
|
224
228
|
// thrown error escape as a 500; Slack's slash-command UX for that is an ugly
|
|
225
229
|
// "operation_timeout"/dispatch_failed toast.
|
|
226
|
-
log(`"
|
|
230
|
+
log(`"${expected}" command failed softly: ${String(err)}`);
|
|
227
231
|
return ephemeralResponse(`Couldn't complete that: ${err instanceof Error ? err.message : String(err)}`);
|
|
228
232
|
}
|
|
229
233
|
}),
|
|
@@ -3682,33 +3682,91 @@ export function fetchChannelDisplayName(channelId: string): Promise<string | und
|
|
|
3682
3682
|
return p;
|
|
3683
3683
|
}
|
|
3684
3684
|
|
|
3685
|
+
/** Slack error codes that mean this app STRUCTURALLY cannot read a room's roster (OPE-2306): a scope
|
|
3686
|
+
* the install does not hold, a room it cannot see, a permission or Grid grant it lacks. A retry
|
|
3687
|
+
* cannot change them, so the roster is `unobservable` and the observed-guest legs stay inert. Every
|
|
3688
|
+
* other non-ok code is `failed` ON PURPOSE (fail closed): an unlisted steady-state code makes the
|
|
3689
|
+
* connector-disclosure floor refuse until it is added here. */
|
|
3690
|
+
const SLACK_ROSTER_UNOBSERVABLE_ERRORS: ReadonlySet<string> = new Set([
|
|
3691
|
+
"missing_scope",
|
|
3692
|
+
"channel_not_found",
|
|
3693
|
+
"not_in_channel",
|
|
3694
|
+
"method_not_supported_for_channel_type",
|
|
3695
|
+
"no_permission",
|
|
3696
|
+
"team_access_not_granted",
|
|
3697
|
+
]);
|
|
3698
|
+
|
|
3699
|
+
/** The unresolved state of a non-ok Slack answer. No answer at all (a network failure, an exhausted
|
|
3700
|
+
* 429 retry, no bot token: slackForm returns `undefined`) is `failed`, and so is an unknown code. */
|
|
3701
|
+
function slackRosterFailureState(res: Record<string, unknown> | undefined): "unobservable" | "failed" {
|
|
3702
|
+
return typeof res?.error === "string" && SLACK_ROSTER_UNOBSERVABLE_ERRORS.has(res.error) ? "unobservable" : "failed";
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3685
3705
|
/**
|
|
3686
3706
|
* Live Slack roster for a governance channel's floor tripwire (OPE-110, access/audience-floor.ts).
|
|
3687
|
-
*
|
|
3688
|
-
* conversations.members (member ids).
|
|
3689
|
-
*
|
|
3690
|
-
*
|
|
3707
|
+
* Up to three Slack reads: conversations.info (is_private + externally-shared bit),
|
|
3708
|
+
* conversations.members (member ids) and auth.test (the bot's own user id). The roster excludes the
|
|
3709
|
+
* bot's own user id, so the agent doesn't count as a non-admin member. A failed conversations.info
|
|
3710
|
+
* or conversations.members read, a members list of a NON-SHARED channel longer than one page, or a
|
|
3711
|
+
* thrown error returns `resolved: false`, with an `unresolvedState` (OPE-2306). A transient failure
|
|
3712
|
+
* is `failed`: a non-ok answer outside SLACK_ROSTER_UNOBSERVABLE_ERRORS, no answer, a thrown error, a
|
|
3713
|
+
* conversations.info answer with no channel object. A steady state is `unobservable`: a code in that
|
|
3714
|
+
* set, or a non-shared members list longer than one page. A failed auth.test does not unresolve the roster: the roster stays resolved and counts the bot as a
|
|
3715
|
+
* non-admin member, so the governance channel does not arm. `resolved: false` is closed for the
|
|
3716
|
+
* governance tripwire: the floor computes to `guest`, so the channel can never arm. It is
|
|
3717
|
+
* also closed for the clicker floor's beneficiary leg, which refuses on it (`unattested-room`). It
|
|
3718
|
+
* is closed for the connector-disclosure floor when it is `failed`: that floor refuses until the
|
|
3719
|
+
* roster resolves (OPE-2306). It is NOT closed for the observed-guest legs of the triggered-fire
|
|
3720
|
+
* disclosure floor, the card-disclosure floor and the approval-card clicker floor, nor for the
|
|
3721
|
+
* connector-disclosure floor on an `unobservable` roster. Those refuse only an OBSERVED guest, so
|
|
3722
|
+
* they stay INERT (access/audience-floor.ts, the polarity rule). Core memoizes a `resolved: false`
|
|
3723
|
+
* answer for UNRESOLVED_ROSTER_TTL_MS (30 s) only, not for the full memo TTL (OPE-2150), so one
|
|
3724
|
+
* failed read disarms the inert legs, and refuses the connector-disclosure floor, for at most that
|
|
3725
|
+
* short window.
|
|
3726
|
+
* `hasGuest` uses the channel's externally-shared bit as the available external signal;
|
|
3691
3727
|
* per-member guest classification rides the guest-detection work (design §2/§10 fragile-point #1) and
|
|
3692
3728
|
* is a future tightening, not a loosening (an internal-only private channel already needs every
|
|
3693
3729
|
* member on the roster to arm). Governance channels are small by config lint, so a single members
|
|
3694
|
-
* page suffices
|
|
3695
|
-
*
|
|
3730
|
+
* page suffices. A NON-SHARED channel larger than one page returns `resolved: false` rather than a
|
|
3731
|
+
* partial roster: the governance channel does not arm, and the disclosure floors stay inert
|
|
3732
|
+
* (`unobservable`: the member list is incomplete and a retry gets the same page count). An
|
|
3733
|
+
* EXTERNALLY-SHARED channel larger than one page resolves with `hasGuest: true` and an empty member
|
|
3734
|
+
* list, without a read of the other pages (OPE-2311). The floor counts the channel's shared bit as
|
|
3735
|
+
* a guest present, the same signal the one-page path uses (the bit includes a pending external
|
|
3736
|
+
* invitation and an Enterprise Grid org share). So the floor is `guest` and the observed-guest legs
|
|
3737
|
+
* of every consumer refuse.
|
|
3696
3738
|
*/
|
|
3697
3739
|
export async function slackGovernanceRoster(channelId: string): Promise<RoomRoster | undefined> {
|
|
3698
3740
|
try {
|
|
3699
3741
|
const info = await slackForm("conversations.info", `channel=${encodeURIComponent(channelId)}`);
|
|
3700
|
-
if (info?.ok !== true)
|
|
3742
|
+
if (info?.ok !== true) {
|
|
3743
|
+
return { humanMemberIds: [], resolved: false, unresolvedState: slackRosterFailureState(info), isPrivate: false, hasGuest: false };
|
|
3744
|
+
}
|
|
3701
3745
|
const facts = channelFactsFromInfo(info);
|
|
3702
3746
|
const members = await slackForm("conversations.members", `channel=${encodeURIComponent(channelId)}&limit=200`);
|
|
3703
3747
|
if (members?.ok !== true || !Array.isArray(members.members)) {
|
|
3704
|
-
return { humanMemberIds: [], resolved: false, isPrivate: false, hasGuest: false };
|
|
3748
|
+
return { humanMemberIds: [], resolved: false, unresolvedState: slackRosterFailureState(members), isPrivate: false, hasGuest: false };
|
|
3705
3749
|
}
|
|
3706
3750
|
const cursor = (members.response_metadata as { next_cursor?: string } | undefined)?.next_cursor;
|
|
3707
3751
|
if (cursor && cursor.length > 0) {
|
|
3708
|
-
// More than one page
|
|
3709
|
-
//
|
|
3710
|
-
// partial
|
|
3711
|
-
|
|
3752
|
+
// More than one page. An externally-shared channel needs no member list: the floor counts the
|
|
3753
|
+
// shared bit as a guest present, as the one-page path does (OPE-2311). So the roster resolves
|
|
3754
|
+
// with `hasGuest: true` and an EMPTY member list, never a partial one. The floor is `guest` for every consumer, and
|
|
3755
|
+
// the observed-guest legs refuse. The share bit is a steady state, so the full memo TTL is fine.
|
|
3756
|
+
if (facts.trustChecked === true && facts.isExternal === true) {
|
|
3757
|
+
return { humanMemberIds: [], resolved: true, isPrivate: facts.isPrivate === true, hasGuest: true };
|
|
3758
|
+
}
|
|
3759
|
+
// An ok conversations.info with no channel object is an odd answer, not a steady state: it is
|
|
3760
|
+
// `failed`, as on the one-page path below.
|
|
3761
|
+
if (facts.trustChecked !== true) {
|
|
3762
|
+
return { humanMemberIds: [], resolved: false, unresolvedState: "failed", isPrivate: false, hasGuest: false };
|
|
3763
|
+
}
|
|
3764
|
+
// A non-shared channel this large: do not attest a partial roster as if it were the whole
|
|
3765
|
+
// audience (a >200-member "admin" room is off-policy anyway). An unresolved roster keeps the
|
|
3766
|
+
// governance channel disarmed, but it leaves the disclosure floors INERT, not closed
|
|
3767
|
+
// (access/audience-floor.ts, the polarity rule). `unobservable` (OPE-2306): a retry gets the
|
|
3768
|
+
// same page count.
|
|
3769
|
+
return { humanMemberIds: [], resolved: false, unresolvedState: "unobservable", isPrivate: false, hasGuest: false };
|
|
3712
3770
|
}
|
|
3713
3771
|
const auth = await slackForm("auth.test", "");
|
|
3714
3772
|
const botId = auth?.ok === true && typeof auth.user_id === "string" ? auth.user_id : undefined;
|
|
@@ -3718,11 +3776,13 @@ export async function slackGovernanceRoster(channelId: string): Promise<RoomRost
|
|
|
3718
3776
|
return {
|
|
3719
3777
|
humanMemberIds,
|
|
3720
3778
|
resolved: facts.trustChecked === true, // conversations.info authoritative
|
|
3779
|
+
// An ok conversations.info with no channel object is an odd answer, not a steady state.
|
|
3780
|
+
...(facts.trustChecked === true ? {} : { unresolvedState: "failed" as const }),
|
|
3721
3781
|
isPrivate: facts.isPrivate === true,
|
|
3722
3782
|
hasGuest: facts.isExternal === true,
|
|
3723
3783
|
};
|
|
3724
3784
|
} catch {
|
|
3725
|
-
return { humanMemberIds: [], resolved: false, isPrivate: false, hasGuest: false };
|
|
3785
|
+
return { humanMemberIds: [], resolved: false, unresolvedState: "failed", isPrivate: false, hasGuest: false };
|
|
3726
3786
|
}
|
|
3727
3787
|
}
|
|
3728
3788
|
|
|
@@ -576,7 +576,7 @@ export interface FrontOptions {
|
|
|
576
576
|
* tests can stub it without the propose/store/executor machinery it drags in.
|
|
577
577
|
*
|
|
578
578
|
* 🔴 IT TAKES THE ROUTE'S `waitUntil` (OPE-1709), for the same reason the Discord front's
|
|
579
|
-
* approval branch does: "Approve for
|
|
579
|
+
* approval branch does: "Approve for this routine" releases the sibling cards the grant already
|
|
580
580
|
* covers, and each release runs a real `execute`. That work belongs off the interactivity
|
|
581
581
|
* response path. The handler falls back to its old awaited shape when this is absent, which is
|
|
582
582
|
* exactly what a stub that ignores the second argument gets. */
|
|
@@ -2058,7 +2058,7 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
2058
2058
|
//
|
|
2059
2059
|
// 🔴 `args.waitUntil` RIDES ALONG (OPE-1709), and it is the HELD one (see the route hold
|
|
2060
2060
|
// above), so the deferred work is quiesced before the delivery's own promise resolves. What
|
|
2061
|
-
// it moves off this path is the session-grant release: an "Approve for
|
|
2061
|
+
// it moves off this path is the session-grant release: an "Approve for this routine" click on a
|
|
2062
2062
|
// fan-out decides and EXECUTES the sibling cards the grant already covers, and that loop has
|
|
2063
2063
|
// no business inside Slack's three-second interactivity budget.
|
|
2064
2064
|
if (await handleApproval(decision.payload, { waitUntil: args.waitUntil })) {
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// THE TASK CARD'S CONFIDENTIALITY SEAM (OPE-2255).
|
|
2
|
+
//
|
|
3
|
+
// eve 0.69 posts a live TASK CARD for every turn that starts a task: one message per turn, one row
|
|
4
|
+
// per task, updated in place. OE adopts eve's own card (Tommy's principle: native first), so this
|
|
5
|
+
// file draws nothing itself. Every card still comes from eve's `renderDefaultSlackTaskCard` through
|
|
6
|
+
// `next`. This file only changes the VIEW that eve draws from.
|
|
7
|
+
//
|
|
8
|
+
// WHY A SEAM. An agent task's row title is the worker BRIEF (`<agent>: <brief>`, or the brief alone
|
|
9
|
+
// for a call to the agent's own copy). The model writes that brief from the whole session context.
|
|
10
|
+
// On a unified domain seat, that context can hold another conversation, for example a DM. eve gates
|
|
11
|
+
// only a failure line and a blocker's label on the room's audience, and eve's `private` audience
|
|
12
|
+
// covers private channels and group DMs too, which have several readers. The title, the
|
|
13
|
+
// notification `text` (`Working on 1 task: <the whole brief>`, not cut) and a completed row's
|
|
14
|
+
// `Done in Xs: <first sentence of the worker's reply>` reach a public channel unchanged
|
|
15
|
+
// (OPE-2246 Q7, measured on a real `actions.requested`).
|
|
16
|
+
//
|
|
17
|
+
// THE RULE. OE cannot trace the provenance of one model-written string. So the rule is on the ROOM,
|
|
18
|
+
// and it fails closed:
|
|
19
|
+
// * A task row shows its own title only in a 1:1 DM whose only human is the turn's own caller.
|
|
20
|
+
// * In every other room (a public channel, a private channel, a group DM, an unknown room) the row
|
|
21
|
+
// shows a generic label made from the task's NAME only. The name is code, not model text. A
|
|
22
|
+
// blocked row there keeps only the KIND of its blocker (`Waiting for a response`), never the
|
|
23
|
+
// request's prompt or the connection's name.
|
|
24
|
+
// * A completed row NEVER shows the worker's result excerpt, in any room. That excerpt bypasses
|
|
25
|
+
// `approvals/review-surface.ts`, which frames a delegated verdict as a model opinion. eve keeps
|
|
26
|
+
// the `Done in Xs` line, so the card still says the task finished.
|
|
27
|
+
// eve builds the notification `text` from the row titles, so the same redaction covers it.
|
|
28
|
+
//
|
|
29
|
+
// WHY A SESSION STATE SLOT. The `taskCard` hook is pure and synchronous, and its view carries no room.
|
|
30
|
+
// The view's `turnId` is no key either: eve numbers turns per session (`turn_<sequence>`), so two
|
|
31
|
+
// sessions in one process share turn ids. eve dispatches each session event inside that session's
|
|
32
|
+
// eve context, and `withTaskCards` writes the card inside the same dispatch. So each tracked event
|
|
33
|
+
// handler below records the room in a `defineState` slot, which is keyed to the session and
|
|
34
|
+
// durable, and the `taskCard` link reads back the LATEST room this session recorded. Most writes
|
|
35
|
+
// follow the handler of their own event. One does not: on `task.started`, eve's `closeSettledCard`
|
|
36
|
+
// writes the closing card BEFORE the handler runs, so that write reads the room an earlier event of
|
|
37
|
+
// the session recorded. That is safe because a session's channel and its DM partner do not change.
|
|
38
|
+
// No slot, or no eve context, reads as a shared room: the card then shows the generic label. It
|
|
39
|
+
// never shows the brief by accident.
|
|
40
|
+
|
|
41
|
+
import { defineState } from "eve/context";
|
|
42
|
+
import type {
|
|
43
|
+
SlackEventContext,
|
|
44
|
+
SlackRenderer,
|
|
45
|
+
SlackRendererEvents,
|
|
46
|
+
SlackTaskCard,
|
|
47
|
+
TaskCardTask,
|
|
48
|
+
TaskCardView,
|
|
49
|
+
} from "eve/channels/slack";
|
|
50
|
+
|
|
51
|
+
/** The room a task card lands in. Only `direct-message` shows a task's own title. */
|
|
52
|
+
export type TaskCardRoom = "direct-message" | "shared";
|
|
53
|
+
|
|
54
|
+
/** The label for a call to the agent's own copy, whose eve name (`agent`) says nothing to a reader. */
|
|
55
|
+
export const GENERIC_SELF_TASK_LABEL = "Delegated task";
|
|
56
|
+
|
|
57
|
+
/** eve's tool name for a call to the agent's own copy (`tools/framework/agent-contract.js`). */
|
|
58
|
+
const AGENT_SELF_TOOL_NAME = "agent";
|
|
59
|
+
|
|
60
|
+
/** The facts the room decision reads: the card's target channel and the turn caller's auth. */
|
|
61
|
+
export interface TaskCardRoomFacts {
|
|
62
|
+
/** eve's channel state: the channel the card posts to, and the room's audience. */
|
|
63
|
+
readonly state: { readonly channelId?: string | null; readonly audience?: string | null };
|
|
64
|
+
/** The turn caller's auth attributes, as OE's `buildSlackAuth` writes them. */
|
|
65
|
+
readonly attributes?: Readonly<Record<string, unknown>> | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Decide the room. A 1:1 DM needs all three facts: the caller's auth says `channel_type: "im"`, the
|
|
70
|
+
* auth names the SAME channel the card posts to, and eve does not call the room public. Anything
|
|
71
|
+
* else, a missing fact included, is a shared room. Pure and total.
|
|
72
|
+
*/
|
|
73
|
+
export function taskCardRoomOf(facts: TaskCardRoomFacts | null | undefined): TaskCardRoom {
|
|
74
|
+
const channelId = facts?.state?.channelId;
|
|
75
|
+
const attrs = facts?.attributes;
|
|
76
|
+
if (typeof channelId !== "string" || channelId === "" || attrs === null || attrs === undefined) return "shared";
|
|
77
|
+
if (attrs.channel_type !== "im") return "shared";
|
|
78
|
+
if (attrs.channel_id !== channelId) return "shared";
|
|
79
|
+
if (facts?.state?.audience === "public") return "shared";
|
|
80
|
+
return "direct-message";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A task's generic label: its name in words, never its brief or its tool's start label. */
|
|
84
|
+
export function genericTaskLabel(task: Pick<TaskCardTask, "kind" | "name">): string {
|
|
85
|
+
if (task.kind === "agent" && task.name === AGENT_SELF_TOOL_NAME) return GENERIC_SELF_TASK_LABEL;
|
|
86
|
+
// eve's own `displayTitle` shape: the last `__` segment, words split on case, `_` and `-`.
|
|
87
|
+
const segment = task.name.split("__").findLast((s) => s !== "") ?? task.name;
|
|
88
|
+
const words = segment
|
|
89
|
+
.replace(/([a-z0-9])([A-Z])/gu, "$1 $2")
|
|
90
|
+
.split(/[\s_-]+/u)
|
|
91
|
+
.filter((w) => w !== "")
|
|
92
|
+
.map((w) => (w.length > 1 && w === w.toUpperCase() ? w : w.toLowerCase()));
|
|
93
|
+
if (words.length === 0) return GENERIC_SELF_TASK_LABEL;
|
|
94
|
+
const text = words.join(" ");
|
|
95
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The view eve draws for this room. Every row loses its result excerpt. In a shared room every row
|
|
100
|
+
* also loses its title for the generic label, and a blocked row loses its blocker's label. A pure
|
|
101
|
+
* function: the input view is never changed.
|
|
102
|
+
*/
|
|
103
|
+
export function confidentialTaskCardView(view: TaskCardView, room: TaskCardRoom): TaskCardView {
|
|
104
|
+
const tasks = view.tasks.map((task): TaskCardTask => {
|
|
105
|
+
const { summary: _summary, blockedOn, ...rest } = task;
|
|
106
|
+
if (room === "direct-message") return blockedOn === undefined ? rest : { ...rest, blockedOn };
|
|
107
|
+
const redacted: TaskCardTask = { ...rest, title: genericTaskLabel(task) };
|
|
108
|
+
return blockedOn === undefined ? redacted : { ...redacted, blockedOn: { kind: blockedOn.kind } };
|
|
109
|
+
});
|
|
110
|
+
return { ...view, tasks };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const roomSlot = defineState<TaskCardRoom | null>("open-everywhere.slack-task-card-room", () => null);
|
|
114
|
+
|
|
115
|
+
/** Record the room for this session's next card write. Outside an eve context it does nothing. */
|
|
116
|
+
function recordTaskCardRoom(channel: Pick<SlackEventContext, "state">, ctx: unknown): void {
|
|
117
|
+
const attributes = (ctx as { session?: { auth?: { current?: { attributes?: Record<string, unknown> } | null } } } | undefined)
|
|
118
|
+
?.session?.auth?.current?.attributes;
|
|
119
|
+
const state = channel.state as unknown as TaskCardRoomFacts["state"];
|
|
120
|
+
const room = taskCardRoomOf({ attributes, state });
|
|
121
|
+
try {
|
|
122
|
+
roomSlot.update(() => room);
|
|
123
|
+
} catch {
|
|
124
|
+
// No eve context: nothing to record. The card then reads no room and shows the generic label.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The room this session recorded, or `shared` when none is readable. */
|
|
129
|
+
function readTaskCardRoom(): TaskCardRoom {
|
|
130
|
+
try {
|
|
131
|
+
return roomSlot.get() ?? "shared";
|
|
132
|
+
} catch {
|
|
133
|
+
return "shared";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The `taskCard` link: redact the view for the recorded room, then let eve draw its default card. */
|
|
138
|
+
export function confidentialTaskCard(
|
|
139
|
+
view: TaskCardView,
|
|
140
|
+
next: (view: TaskCardView) => SlackTaskCard | null,
|
|
141
|
+
room: TaskCardRoom = readTaskCardRoom(),
|
|
142
|
+
): SlackTaskCard | null {
|
|
143
|
+
return next(confidentialTaskCardView(view, room));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The events after which eve writes a card: eve's `TRACKED_EVENTS` in
|
|
147
|
+
// `public/channels/slack/task-card.js`, all eleven. `authorization.required` gets eve's private
|
|
148
|
+
// delivery surface as its channel, which still carries the session's `state`.
|
|
149
|
+
const RECORDING_EVENTS = [
|
|
150
|
+
"authorization.required",
|
|
151
|
+
"actions.requested",
|
|
152
|
+
"action.result",
|
|
153
|
+
"task.started",
|
|
154
|
+
"task.settled",
|
|
155
|
+
"input.requested",
|
|
156
|
+
"input.resolved",
|
|
157
|
+
"authorization.completed",
|
|
158
|
+
"turn.completed",
|
|
159
|
+
"turn.failed",
|
|
160
|
+
"turn.cancelled",
|
|
161
|
+
] as const;
|
|
162
|
+
|
|
163
|
+
function recordingEvents(): SlackRendererEvents {
|
|
164
|
+
const events: Record<string, unknown> = {};
|
|
165
|
+
for (const name of RECORDING_EVENTS) {
|
|
166
|
+
events[name] = async (_data: unknown, channel: SlackEventContext, ctx: unknown, next: () => Promise<void>) => {
|
|
167
|
+
recordTaskCardRoom(channel, ctx);
|
|
168
|
+
await next();
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return events as SlackRendererEvents;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The renderer link. Put it FIRST in the chain, so its handlers record the room before any later
|
|
176
|
+
* link, which may not call `next`, ends the chain. Each handler records and then calls `next`, so it
|
|
177
|
+
* changes no rendering of its own.
|
|
178
|
+
*/
|
|
179
|
+
export const taskCardConfidentiality: SlackRenderer = {
|
|
180
|
+
events: recordingEvents(),
|
|
181
|
+
taskCard: (view, next) => confidentialTaskCard(view, next),
|
|
182
|
+
};
|