@open-everywhere/provider-slack 0.1.4 → 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.
Files changed (57) hide show
  1. package/NOTICE +34 -4
  2. package/README.md +1 -1
  3. package/agent/app-manifest.ts +44 -9
  4. package/agent/channels/slack-commands.ts +9 -5
  5. package/agent/channels/slack-events.ts +73 -13
  6. package/agent/channels/slack-front.ts +25 -3
  7. package/agent/channels/slack-task-card.ts +182 -0
  8. package/agent/channels/slack.ts +518 -468
  9. package/agent/contributions.ts +1 -1
  10. package/agent/docs-generated.ts +1 -1
  11. package/agent/ops.ts +20 -2
  12. package/agent/setup/eve-credentials.ts +10 -2
  13. package/agent/setup/page.ts +12 -9
  14. package/agent/status.ts +28 -15
  15. package/dist/agent/app-manifest.d.ts +20 -4
  16. package/dist/agent/app-manifest.d.ts.map +1 -1
  17. package/dist/agent/app-manifest.js +29 -6
  18. package/dist/agent/app-manifest.js.map +1 -1
  19. package/dist/agent/channels/slack-commands.d.ts.map +1 -1
  20. package/dist/agent/channels/slack-commands.js +9 -6
  21. package/dist/agent/channels/slack-commands.js.map +1 -1
  22. package/dist/agent/channels/slack-events.d.ts +28 -6
  23. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  24. package/dist/agent/channels/slack-events.js +71 -14
  25. package/dist/agent/channels/slack-events.js.map +1 -1
  26. package/dist/agent/channels/slack-front.d.ts +4 -1
  27. package/dist/agent/channels/slack-front.d.ts.map +1 -1
  28. package/dist/agent/channels/slack-front.js +21 -3
  29. package/dist/agent/channels/slack-front.js.map +1 -1
  30. package/dist/agent/channels/slack-task-card.d.ts +38 -0
  31. package/dist/agent/channels/slack-task-card.d.ts.map +1 -0
  32. package/dist/agent/channels/slack-task-card.js +156 -0
  33. package/dist/agent/channels/slack-task-card.js.map +1 -0
  34. package/dist/agent/channels/slack.d.ts.map +1 -1
  35. package/dist/agent/channels/slack.js +516 -466
  36. package/dist/agent/channels/slack.js.map +1 -1
  37. package/dist/agent/contributions.d.ts +1 -1
  38. package/dist/agent/contributions.d.ts.map +1 -1
  39. package/dist/agent/contributions.js +1 -1
  40. package/dist/agent/contributions.js.map +1 -1
  41. package/dist/agent/docs-generated.js +1 -1
  42. package/dist/agent/docs-generated.js.map +1 -1
  43. package/dist/agent/ops.d.ts +5 -0
  44. package/dist/agent/ops.d.ts.map +1 -1
  45. package/dist/agent/ops.js +13 -2
  46. package/dist/agent/ops.js.map +1 -1
  47. package/dist/agent/setup/eve-credentials.d.ts +9 -2
  48. package/dist/agent/setup/eve-credentials.d.ts.map +1 -1
  49. package/dist/agent/setup/eve-credentials.js +9 -1
  50. package/dist/agent/setup/eve-credentials.js.map +1 -1
  51. package/dist/agent/setup/page.d.ts.map +1 -1
  52. package/dist/agent/setup/page.js +12 -10
  53. package/dist/agent/setup/page.js.map +1 -1
  54. package/dist/agent/status.d.ts.map +1 -1
  55. package/dist/agent/status.js +23 -15
  56. package/dist/agent/status.js.map +1 -1
  57. package/package.json +13 -5
package/NOTICE CHANGED
@@ -8,10 +8,40 @@ This product is licensed under the Apache License, Version 2.0 (see LICENSE).
8
8
  Built on Vercel Eve (https://github.com/vercel/eve), licensed under the
9
9
  Apache License, Version 2.0.
10
10
 
11
- @open-everywhere/core also ships a patch that modifies Vercel Eve (the file in
12
- its patches/ directory), with the oe-eve-patch command that applies it to a
13
- consumer's own installed copy. The patch is licensed under the Apache License,
14
- Version 2.0. No modified Vercel Eve files are distributed.
11
+ ---
12
+
13
+ Modifications to eve
14
+
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
+ "Copyright 2026 Vercel, Inc. and contributors". The Open Everywhere authors made
19
+ the changes in the patch file.
20
+
21
+ The patch modifies four files of eve 0.71.0:
22
+
23
+ dist/src/execution/web-fetch/request.js
24
+ dist/src/harness/tool-loop.js
25
+ dist/src/internal/authored-package-boundary.js
26
+ dist/src/protocol/message.d.ts
27
+
28
+ Open Everywhere does not ship the eve package. eve is a peer dependency, so the
29
+ consumer installs eve 0.71.0 from the registry. The patch file carries the
30
+ modified portions of eve. The oe-eve-patch command, which
31
+ @open-everywhere/core ships, applies them to the eve that the consumer
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
+
36
+ The attribution notice of eve 0.71.0 follows, as section 4(d) of the Apache
37
+ License, Version 2.0 requires. The notices for eve's bundled code-mode runtime
38
+ are not reproduced, because the patch modifies no part of that runtime.
39
+
40
+ eve
41
+ Copyright 2026 Vercel, Inc. and contributors
42
+
43
+ This product includes software developed at Vercel, Inc.
44
+ (https://vercel.com/).
15
45
 
16
46
  ---
17
47
 
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.54.3
18
+ npm i @open-everywhere/core @open-everywhere/provider-slack eve@0.71.0
19
19
  npx oe-eve-patch
20
20
  ```
21
21
 
@@ -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
- /** The app name and the bot user's display name. Default {@link SLACK_DEFAULT_APP_NAME}. A
37
- * second install in one workspace needs its own name, or the two apps look the same. */
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.appName?.trim() || SLACK_DEFAULT_APP_NAME;
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
- command: "/oe",
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: "Open Everywhere control-plane commands (stop, status, budget) — no model turn.",
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 Open Everywhere",
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 Open Everywhere",
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
- if (command !== "/oe") {
208
- // Defensive, not a security gate: this route is dedicated to "/oe" — the Slack app's
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 /oe route — check the Slack app's Slash Command config`);
213
- return ephemeralResponse('This endpoint only handles "/oe" — check your Slack app\'s Slash Command configuration.');
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(`"/oe" command failed softly: ${String(err)}`);
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
- * Two fail-closed Slack reads: conversations.info (is_private + externally-shared bit) and
3688
- * conversations.members (member ids). The bot's own user id is excluded so the agent doesn't count
3689
- * as a non-admin member. FAIL CLOSED: any failed/absent lookup ⇒ `resolved: false` ⇒ the floor can
3690
- * never arm. `hasGuest` uses the channel's externally-shared bit as the available external signal;
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; a channel larger than one page fails closed on the (unset) cursor rather than
3695
- * silently attesting a partial roster.
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) return { humanMemberIds: [], resolved: false, isPrivate: false, hasGuest: false };
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: a governance channel this large is off-policy (lint caps channel COUNT,
3709
- // not membership, but a >200-member "admin" room is a smell) — fail closed rather than attest a
3710
- // partial roster as if it were the whole audience.
3711
- return { humanMemberIds: [], resolved: false, isPrivate: false, hasGuest: false };
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
 
@@ -119,6 +119,7 @@ import {
119
119
  tailAuthorGrain,
120
120
  dmWindowRegistryKey,
121
121
  approvalCardRespondVia,
122
+ stampSurfaceEventVerified,
122
123
  } from "@open-everywhere/core/agent/adapter-api.js";
123
124
  import {
124
125
  renderWakeMessage,
@@ -559,6 +560,9 @@ export interface FrontOptions {
559
560
  readonly now?: () => number;
560
561
  /** Durable first-sighting check (true = proceed). Defaults to the operational store. */
561
562
  readonly markEventSeen?: (org: string, key: string, atIso: string) => Promise<boolean>;
563
+ /** The durable "a signed event was verified" stamp (OPE-2242), called only after a verify
564
+ * passes. Defaults to core's `stampSurfaceEventVerified("slack")`. */
565
+ readonly stampEventVerified?: () => Promise<unknown>;
562
566
  /** Is ambient-supervisor mode enabled for this channel's domain? (sync config lookup.)
563
567
  * Defaults to the live domain resolver (memory/domain.ts). Fail-closed off: when this
564
568
  * returns false the unowned-message dispatch is BYTE-IDENTICAL to today's name-drop path. */
@@ -572,7 +576,7 @@ export interface FrontOptions {
572
576
  * tests can stub it without the propose/store/executor machinery it drags in.
573
577
  *
574
578
  * 🔴 IT TAKES THE ROUTE'S `waitUntil` (OPE-1709), for the same reason the Discord front's
575
- * approval branch does: "Approve for session" releases the sibling cards the grant already
579
+ * approval branch does: "Approve for this routine" releases the sibling cards the grant already
576
580
  * covers, and each release runs a real `execute`. That work belongs off the interactivity
577
581
  * response path. The handler falls back to its old awaited shape when this is absent, which is
578
582
  * exactly what a stub that ignores the second argument gets. */
@@ -1017,6 +1021,7 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1017
1021
  // the same property read it always was for every pre-OPE-2231 deployment.
1018
1022
  const resolveSigningSecret = async (): Promise<string | undefined> => opts.signingSecret ?? (await slackSigningSecret());
1019
1023
  const markEventSeen = opts.markEventSeen ?? liveMarkEventSeen;
1024
+ const stampEventVerified = opts.stampEventVerified ?? (() => stampSurfaceEventVerified("slack"));
1020
1025
  // OPE-589 leg D: reuse the taint-marking member probe (slack-events.ts `slackClickerIsGuest`,
1021
1026
  // itself `slackAuthorIsMember` inverted). Same `users.info` call and cache the channel-taint leg
1022
1027
  // already pays for, so a clicker who recently posted a message in the same workspace is often a
@@ -1082,6 +1087,19 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1082
1087
  if (!verifySlackSignature(rawBody, req.headers, requestSigningSecret, now())) {
1083
1088
  return new Response("unauthorized", { status: 401 });
1084
1089
  }
1090
+ // 🔴 THE DURABLE PROOF THAT THIS GATE ACCEPTED A SIGNED DELIVERY (OPE-2242), and it sits HERE —
1091
+ // after the verify, before any classification — on purpose. Every Slack delivery crosses this
1092
+ // line, whatever its envelope, its domain topology or its turn's outcome, so `eventsVerified` on
1093
+ // the status route no longer depends on a session row whose key may carry no platform marker.
1094
+ // It is a TRACKED write (`holdRouteWork`), not a `waitUntil` of its own: it rides whatever
1095
+ // background work this delivery registers, so the existing registration counts do not change. A
1096
+ // NATIVE FORWARD also AWAITS it (`forwardNative` below), because a forward can register no
1097
+ // background work at all — eve answers the install-time `url_verification` challenge inline — and
1098
+ // an unheld write there can be frozen after the 200 and lost. The debounce makes that await a
1099
+ // real write at most once an hour per process; every other await resolves at once. On the other
1100
+ // doors the write stays best-effort when the delivery registers nothing. It is fail-soft in core,
1101
+ // so it never fails the delivery.
1102
+ const stamped = holdRouteWork(stampEventVerified().catch(() => undefined));
1085
1103
  const decision = classifyEnvelope(rawBody, req.headers.get("content-type") ?? "");
1086
1104
 
1087
1105
  /**
@@ -1117,7 +1135,11 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1117
1135
  * EVERY native forward is wrapped for the same reason the attachment fence is: an `im` message
1118
1136
  * forwards natively too, so a rung carried on the mention path alone would be half a rung.
1119
1137
  */
1120
- const forwardNative = (): Promise<Response> =>
1138
+ const forwardNative = async (): Promise<Response> => {
1139
+ await stamped;
1140
+ return forwardNativeNow();
1141
+ };
1142
+ const forwardNativeNow = (): Promise<Response> =>
1121
1143
  withForwardedSlackEnvelope(forwardedEnvelopeOf(rawBody), () => {
1122
1144
  const fenced = stripUnfencedSlackAttachments(rawBody);
1123
1145
  const ts = req.headers.get(SLACK_TIMESTAMP_HEADER);
@@ -2036,7 +2058,7 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2036
2058
  //
2037
2059
  // 🔴 `args.waitUntil` RIDES ALONG (OPE-1709), and it is the HELD one (see the route hold
2038
2060
  // above), so the deferred work is quiesced before the delivery's own promise resolves. What
2039
- // it moves off this path is the session-grant release: an "Approve for session" click on a
2061
+ // it moves off this path is the session-grant release: an "Approve for this routine" click on a
2040
2062
  // fan-out decides and EXECUTES the sibling cards the grant already covers, and that loop has
2041
2063
  // no business inside Slack's three-second interactivity budget.
2042
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
+ };