@fastagent-sh/fastagent 0.21.1 → 0.22.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/README.md +9 -12
- package/dist/agent.d.ts +24 -37
- package/dist/agent.js +7 -15
- package/dist/atomic-write.d.ts +13 -17
- package/dist/atomic-write.js +18 -29
- package/dist/bind.d.ts +12 -22
- package/dist/bind.js +18 -27
- package/dist/channel.d.ts +11 -33
- package/dist/channels/agentcore-limits.d.ts +2 -13
- package/dist/channels/agentcore-limits.js +2 -13
- package/dist/channels/agentcore-protocol.d.ts +67 -58
- package/dist/channels/agentcore-protocol.js +23 -15
- package/dist/channels/agentcore-service.d.ts +19 -33
- package/dist/channels/agentcore-service.js +176 -59
- package/dist/channels/agentcore.d.ts +39 -33
- package/dist/channels/agentcore.js +90 -224
- package/dist/channels/body.d.ts +15 -2
- package/dist/channels/body.js +24 -2
- package/dist/channels/busy.d.ts +1 -21
- package/dist/channels/busy.js +2 -39
- package/dist/channels/control.d.ts +17 -62
- package/dist/channels/control.js +74 -182
- package/dist/channels/define-channel.d.ts +39 -0
- package/dist/channels/define-channel.js +9 -0
- package/dist/channels/discover.d.ts +10 -31
- package/dist/channels/discover.js +37 -31
- package/dist/channels/feishu/bootstrap-token.d.ts +1 -8
- package/dist/channels/feishu/bootstrap-token.js +7 -23
- package/dist/channels/feishu/card.d.ts +14 -28
- package/dist/channels/feishu/card.js +16 -30
- package/dist/channels/feishu/cloud.d.ts +1 -5
- package/dist/channels/feishu/context-buffer.d.ts +4 -23
- package/dist/channels/feishu/context-buffer.js +8 -31
- package/dist/channels/feishu/crypto.d.ts +10 -5
- package/dist/channels/feishu/crypto.js +12 -19
- package/dist/channels/feishu/feishu-api.d.ts +2 -1
- package/dist/channels/feishu/feishu-api.js +5 -4
- package/dist/channels/feishu/feishu.d.ts +4 -10
- package/dist/channels/feishu/feishu.js +113 -227
- package/dist/channels/feishu/invoke-turn.d.ts +5 -4
- package/dist/channels/feishu/invoke-turn.js +36 -54
- package/dist/channels/feishu/model.d.ts +4 -21
- package/dist/channels/feishu/model.js +1 -8
- package/dist/channels/feishu/normalize.d.ts +2 -10
- package/dist/channels/feishu/normalize.js +7 -42
- package/dist/channels/feishu/parse.d.ts +9 -29
- package/dist/channels/feishu/parse.js +8 -26
- package/dist/channels/feishu/preview.d.ts +12 -14
- package/dist/channels/feishu/preview.js +108 -207
- package/dist/channels/feishu/register-app.d.ts +8 -23
- package/dist/channels/feishu/register-app.js +7 -20
- package/dist/channels/feishu/register-webhook.d.ts +2 -9
- package/dist/channels/feishu/register-webhook.js +9 -33
- package/dist/channels/feishu/scaffold/channel.ts +27 -19
- package/dist/channels/feishu/scaffold/channel.websocket.ts +31 -0
- package/dist/channels/feishu/setup-mode.d.ts +11 -36
- package/dist/channels/feishu/setup-mode.js +7 -26
- package/dist/channels/feishu/ws-ingress.d.ts +1 -2
- package/dist/channels/feishu/ws-ingress.js +6 -8
- package/dist/channels/http.d.ts +15 -15
- package/dist/channels/http.js +19 -9
- package/dist/channels/kit/attachment-path.d.ts +4 -5
- package/dist/channels/kit/attachment-path.js +10 -34
- package/dist/channels/kit/context-buffer.d.ts +14 -21
- package/dist/channels/kit/context-buffer.js +25 -38
- package/dist/channels/kit/delivery.d.ts +35 -0
- package/dist/channels/kit/delivery.js +129 -0
- package/dist/channels/kit/event-stream.d.ts +5 -0
- package/dist/channels/kit/event-stream.js +29 -0
- package/dist/channels/kit/invoke-turn-kit.d.ts +53 -26
- package/dist/channels/kit/invoke-turn-kit.js +89 -32
- package/dist/channels/kit/preview-kit.d.ts +18 -72
- package/dist/channels/kit/preview-kit.js +22 -88
- package/dist/channels/kit/signature.d.ts +3 -9
- package/dist/channels/kit/signature.js +3 -9
- package/dist/channels/kit/state.d.ts +1 -2
- package/dist/channels/kit/state.js +5 -14
- package/dist/channels/kit/stop-command.d.ts +1 -2
- package/dist/channels/kit/stop-command.js +6 -9
- package/dist/channels/kit/tasks.d.ts +1 -1
- package/dist/channels/kit/tasks.js +8 -21
- package/dist/channels/kit/text.d.ts +2 -16
- package/dist/channels/kit/text.js +23 -31
- package/dist/channels/kit/thread-participants.d.ts +4 -17
- package/dist/channels/kit/thread-participants.js +8 -55
- package/dist/channels/kit/transport.d.ts +26 -0
- package/dist/channels/kit/transport.js +20 -0
- package/dist/channels/kit/turn-queue.d.ts +7 -11
- package/dist/channels/kit/turn-queue.js +21 -39
- package/dist/channels/kit/turn-runner.d.ts +32 -18
- package/dist/channels/kit/turn-runner.js +98 -53
- package/dist/channels/kit/turn-store.d.ts +32 -32
- package/dist/channels/kit/turn-store.js +45 -88
- package/dist/channels/lark/lark.d.ts +1 -5
- package/dist/channels/lark/onboard.d.ts +3 -12
- package/dist/channels/lark/onboard.js +3 -13
- package/dist/channels/lark/scaffold/channel.ts +27 -19
- package/dist/channels/lark/scaffold/channel.websocket.ts +31 -0
- package/dist/channels/registration.d.ts +9 -35
- package/dist/channels/registration.js +6 -37
- package/dist/channels/secret.js +2 -5
- package/dist/channels/serve.d.ts +47 -44
- package/dist/channels/serve.js +165 -101
- package/dist/channels/slack/config-api.d.ts +4 -27
- package/dist/channels/slack/config-api.js +5 -34
- package/dist/channels/slack/context-buffer.d.ts +2 -3
- package/dist/channels/slack/context-buffer.js +5 -5
- package/dist/channels/slack/invoke-turn.d.ts +3 -1
- package/dist/channels/slack/invoke-turn.js +25 -34
- package/dist/channels/slack/manifest.js +1 -3
- package/dist/channels/slack/model.d.ts +1 -2
- package/dist/channels/slack/model.js +1 -1
- package/dist/channels/slack/onboard.d.ts +4 -3
- package/dist/channels/slack/onboard.js +8 -11
- package/dist/channels/slack/onboarding-state.d.ts +3 -5
- package/dist/channels/slack/onboarding-state.js +9 -6
- package/dist/channels/slack/parse.d.ts +4 -13
- package/dist/channels/slack/parse.js +7 -28
- package/dist/channels/slack/preview.d.ts +12 -2
- package/dist/channels/slack/preview.js +292 -269
- package/dist/channels/slack/reaction.d.ts +11 -3
- package/dist/channels/slack/reaction.js +19 -8
- package/dist/channels/slack/register-webhook.js +4 -7
- package/dist/channels/slack/scaffold/channel.ts +20 -13
- package/dist/channels/slack/scaffold/slack-send.ts +2 -2
- package/dist/channels/slack/setup-server.d.ts +1 -5
- package/dist/channels/slack/setup-server.js +1 -5
- package/dist/channels/slack/shared-api.d.ts +1 -4
- package/dist/channels/slack/shared-api.js +2 -13
- package/dist/channels/slack/slack-api.d.ts +2 -1
- package/dist/channels/slack/slack-api.js +6 -4
- package/dist/channels/slack/slack.d.ts +11 -14
- package/dist/channels/slack/slack.js +121 -122
- package/dist/channels/sse.d.ts +1 -1
- package/dist/channels/sse.js +1 -1
- package/dist/channels/telegram/context-buffer.d.ts +7 -22
- package/dist/channels/telegram/context-buffer.js +6 -23
- package/dist/channels/telegram/invoke-turn.d.ts +7 -7
- package/dist/channels/telegram/invoke-turn.js +24 -43
- package/dist/channels/telegram/parse.d.ts +24 -51
- package/dist/channels/telegram/parse.js +32 -84
- package/dist/channels/telegram/preview.d.ts +10 -1
- package/dist/channels/telegram/preview.js +79 -100
- package/dist/channels/telegram/register-webhook.js +5 -23
- package/dist/channels/telegram/scaffold/channel.ts +25 -18
- package/dist/channels/telegram/scaffold/telegram-send.ts +6 -4
- package/dist/channels/telegram/telegram-api.d.ts +4 -3
- package/dist/channels/telegram/telegram-api.js +9 -7
- package/dist/channels/telegram/telegram.d.ts +4 -18
- package/dist/channels/telegram/telegram.js +38 -94
- package/dist/channels/telegram/turn-store.d.ts +11 -14
- package/dist/channels/telegram/turn-store.js +8 -6
- package/dist/channels/wait-health.d.ts +4 -6
- package/dist/channels/wait-health.js +10 -14
- package/dist/cli/add-feishu.d.ts +2 -12
- package/dist/cli/add-feishu.js +24 -71
- package/dist/cli/add-slack.d.ts +5 -3
- package/dist/cli/add-slack.js +53 -76
- package/dist/cli/auth-view.d.ts +2 -16
- package/dist/cli/auth-view.js +2 -16
- package/dist/cli/commands/add.js +25 -40
- package/dist/cli/commands/chat.d.ts +0 -1
- package/dist/cli/commands/chat.js +5 -6
- package/dist/cli/commands/deploy/agentcore.js +60 -65
- package/dist/cli/commands/deploy/docker.js +50 -23
- package/dist/cli/commands/deploy/fly.js +23 -61
- package/dist/cli/commands/deploy/railway.js +23 -18
- package/dist/cli/commands/deploy/shared.d.ts +38 -57
- package/dist/cli/commands/deploy/shared.js +40 -59
- package/dist/cli/commands/deploy.d.ts +10 -27
- package/dist/cli/commands/deploy.js +38 -60
- package/dist/cli/commands/destroy.d.ts +4 -0
- package/dist/cli/commands/destroy.js +56 -0
- package/dist/cli/commands/dev.d.ts +2 -1
- package/dist/cli/commands/dev.js +20 -23
- package/dist/cli/commands/info.d.ts +0 -2
- package/dist/cli/commands/info.js +83 -49
- package/dist/cli/commands/init.d.ts +1 -2
- package/dist/cli/commands/init.js +16 -55
- package/dist/cli/commands/invoke.d.ts +0 -1
- package/dist/cli/commands/invoke.js +5 -7
- package/dist/cli/commands/login.d.ts +2 -1
- package/dist/cli/commands/login.js +33 -38
- package/dist/cli/commands/logs.js +3 -4
- package/dist/cli/commands/models.js +1 -5
- package/dist/cli/commands/routine-run.d.ts +6 -0
- package/dist/cli/commands/routine-run.js +47 -0
- package/dist/cli/commands/routine.d.ts +25 -0
- package/dist/cli/commands/routine.js +122 -0
- package/dist/cli/commands/start.d.ts +31 -3
- package/dist/cli/commands/start.js +131 -81
- package/dist/cli/commands/tool.js +26 -12
- package/dist/cli/fail.d.ts +13 -23
- package/dist/cli/fail.js +20 -23
- package/dist/cli/invoke-stream.d.ts +1 -8
- package/dist/cli/kernel.d.ts +6 -38
- package/dist/cli/kernel.js +14 -77
- package/dist/cli/models-view.d.ts +1 -9
- package/dist/cli/models-view.js +11 -22
- package/dist/cli/program.d.ts +5 -6
- package/dist/cli/program.js +120 -157
- package/dist/cli/serve.d.ts +57 -41
- package/dist/cli/serve.js +94 -121
- package/dist/cli/shared.d.ts +15 -61
- package/dist/cli/shared.js +55 -181
- package/dist/cli.js +1 -8
- package/dist/collect.d.ts +6 -0
- package/dist/collect.js +6 -0
- package/dist/core.d.ts +4 -1
- package/dist/core.js +9 -7
- package/dist/declared-secrets.d.ts +55 -0
- package/dist/declared-secrets.js +88 -0
- package/dist/deploy/agentcore/aws-cli.d.ts +91 -0
- package/dist/deploy/agentcore/aws-cli.js +64 -0
- package/dist/deploy/agentcore/destroy.d.ts +51 -0
- package/dist/deploy/agentcore/destroy.js +273 -0
- package/dist/deploy/agentcore/forwarder.js +28 -79
- package/dist/deploy/agentcore/logs.d.ts +2 -18
- package/dist/deploy/agentcore/logs.js +27 -51
- package/dist/deploy/agentcore/plan.d.ts +85 -105
- package/dist/deploy/agentcore/plan.js +152 -314
- package/dist/deploy/agentcore/run.d.ts +28 -28
- package/dist/deploy/agentcore/run.js +227 -306
- package/dist/deploy/agentcore/zip.d.ts +2 -15
- package/dist/deploy/agentcore/zip.js +3 -17
- package/dist/deploy/build-context.d.ts +21 -0
- package/dist/deploy/build-context.js +137 -0
- package/dist/deploy/channel-ingress.d.ts +26 -39
- package/dist/deploy/channel-ingress.js +57 -28
- package/dist/deploy/container.d.ts +20 -34
- package/dist/deploy/container.js +49 -100
- package/dist/deploy/docker/plan.d.ts +12 -11
- package/dist/deploy/docker/plan.js +43 -24
- package/dist/deploy/docker/run.d.ts +22 -25
- package/dist/deploy/docker/run.js +39 -37
- package/dist/deploy/fly/plan.d.ts +15 -44
- package/dist/deploy/fly/plan.js +41 -73
- package/dist/deploy/fly/run.d.ts +16 -51
- package/dist/deploy/fly/run.js +42 -95
- package/dist/deploy/hosts.d.ts +24 -3
- package/dist/deploy/hosts.js +24 -3
- package/dist/deploy/preflight.d.ts +47 -41
- package/dist/deploy/preflight.js +282 -383
- package/dist/deploy/railway/plan.d.ts +16 -42
- package/dist/deploy/railway/plan.js +42 -60
- package/dist/deploy/railway/run.d.ts +30 -68
- package/dist/deploy/railway/run.js +86 -100
- package/dist/deploy/registration-gate.d.ts +6 -13
- package/dist/deploy/residency.d.ts +37 -0
- package/dist/deploy/residency.js +24 -0
- package/dist/deploy/runner.d.ts +10 -9
- package/dist/deploy/runner.js +7 -18
- package/dist/deploy/secrets.d.ts +40 -41
- package/dist/deploy/secrets.js +117 -103
- package/dist/deploy/workspace.d.ts +50 -0
- package/dist/deploy/workspace.js +197 -0
- package/dist/dev-supervisor.d.ts +1 -9
- package/dist/dev-supervisor.js +21 -64
- package/dist/effect-port.d.ts +50 -0
- package/dist/effect-port.js +77 -0
- package/dist/engines/pi/agent-session-factory.d.ts +50 -69
- package/dist/engines/pi/agent-session-factory.js +249 -146
- package/dist/engines/pi/auth.d.ts +15 -11
- package/dist/engines/pi/auth.js +96 -99
- package/dist/engines/pi/chat.d.ts +1 -5
- package/dist/engines/pi/chat.js +2 -14
- package/dist/engines/pi/config.d.ts +53 -51
- package/dist/engines/pi/config.js +83 -92
- package/dist/engines/pi/create.d.ts +60 -75
- package/dist/engines/pi/create.js +108 -141
- package/dist/engines/pi/definition.d.ts +14 -58
- package/dist/engines/pi/definition.js +21 -85
- package/dist/engines/pi/invoke-session.d.ts +5 -25
- package/dist/engines/pi/invoke-session.js +233 -244
- package/dist/engines/pi/login.d.ts +95 -35
- package/dist/engines/pi/login.js +259 -107
- package/dist/engines/pi/machine.d.ts +31 -0
- package/dist/engines/pi/machine.js +106 -0
- package/dist/engines/pi/models.d.ts +70 -89
- package/dist/engines/pi/models.js +188 -81
- package/dist/engines/pi/open.d.ts +65 -53
- package/dist/engines/pi/open.js +129 -69
- package/dist/engines/pi/report.d.ts +3 -12
- package/dist/engines/pi/report.js +2 -16
- package/dist/engines/pi/search-tools.d.ts +1 -9
- package/dist/engines/pi/search-tools.js +26 -57
- package/dist/engines/pi/service.d.ts +2 -16
- package/dist/engines/pi/service.js +2 -16
- package/dist/engines/pi/session-builder.d.ts +5 -9
- package/dist/engines/pi/session-builder.js +44 -82
- package/dist/engines/pi/session-control.js +286 -269
- package/dist/engines/pi/session-effects.d.ts +14 -0
- package/dist/engines/pi/session-effects.js +30 -0
- package/dist/engines/pi/session-inheritance.d.ts +9 -18
- package/dist/engines/pi/session-inheritance.js +103 -160
- package/dist/engines/pi/session-markers.d.ts +31 -30
- package/dist/engines/pi/session-markers.js +26 -25
- package/dist/engines/pi/session-settings.d.ts +6 -22
- package/dist/engines/pi/session-settings.js +10 -21
- package/dist/engines/pi/session-store.d.ts +26 -92
- package/dist/engines/pi/session-store.js +96 -310
- package/dist/engines/pi/tool-context.d.ts +17 -45
- package/dist/engines/pi/tool-context.js +7 -26
- package/dist/engines/pi/tool.d.ts +45 -36
- package/dist/engines/pi/tool.js +43 -57
- package/dist/engines/pi/turn-kit.d.ts +14 -52
- package/dist/engines/pi/turn-kit.js +18 -67
- package/dist/engines/pi/wake-tool.d.ts +2 -10
- package/dist/engines/pi/wake-tool.js +7 -27
- package/dist/env.d.ts +27 -28
- package/dist/env.js +45 -50
- package/dist/index.js +0 -2
- package/dist/lark.d.ts +1 -2
- package/dist/lark.js +1 -2
- package/dist/loader.d.ts +14 -55
- package/dist/loader.js +24 -43
- package/dist/log.d.ts +2 -12
- package/dist/log.js +3 -24
- package/dist/node.d.ts +2 -15
- package/dist/node.js +2 -18
- package/dist/observe.d.ts +2 -8
- package/dist/open-url.d.ts +1 -1
- package/dist/open-url.js +1 -1
- package/dist/paths.d.ts +70 -134
- package/dist/paths.js +165 -232
- package/dist/pi.d.ts +4 -6
- package/dist/pi.js +3 -3
- package/dist/proxy.d.ts +16 -6
- package/dist/proxy.js +52 -16
- package/dist/runtime.d.ts +18 -12
- package/dist/runtime.js +18 -11
- package/dist/scaffold/add-channel.d.ts +14 -31
- package/dist/scaffold/add-channel.js +48 -112
- package/dist/scaffold/init.d.ts +9 -31
- package/dist/scaffold/init.js +45 -138
- package/dist/scaffold/templates/env.example +6 -4
- package/dist/scaffold/templates/fastagent.config.ts +36 -0
- package/dist/scaffold/templates.d.ts +3 -3
- package/dist/scaffold/templates.js +5 -8
- package/dist/scaffold/vendor-skill.d.ts +2 -5
- package/dist/scaffold/vendor-skill.js +19 -29
- package/dist/schedule/cron.d.ts +9 -5
- package/dist/schedule/cron.js +9 -11
- package/dist/schedule/discover.d.ts +12 -12
- package/dist/schedule/discover.js +43 -34
- package/dist/schedule/routine.d.ts +43 -0
- package/dist/schedule/routine.js +32 -0
- package/dist/schedule/run.d.ts +55 -0
- package/dist/schedule/run.js +146 -0
- package/dist/schedule/scheduler.d.ts +44 -55
- package/dist/schedule/scheduler.js +291 -265
- package/dist/schedule/state.d.ts +105 -10
- package/dist/schedule/state.js +293 -19
- package/dist/schedule/wake-alarm.d.ts +10 -23
- package/dist/schedule/wake-alarm.js +91 -121
- package/dist/schedule/wakeups.d.ts +23 -39
- package/dist/schedule/wakeups.js +33 -75
- package/dist/secrets-gate.d.ts +32 -0
- package/dist/secrets-gate.js +33 -0
- package/dist/service.d.ts +137 -98
- package/dist/service.js +250 -252
- package/dist/session-remote.d.ts +33 -44
- package/dist/session-remote.js +203 -192
- package/dist/session.d.ts +166 -229
- package/dist/session.js +33 -55
- package/dist/tunnel.d.ts +8 -36
- package/dist/tunnel.js +24 -65
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/package.json +10 -16
- package/dist/channels/agentcore-state.d.ts +0 -77
- package/dist/channels/agentcore-state.js +0 -276
- package/dist/channels/github/github.d.ts +0 -31
- package/dist/channels/github/github.js +0 -89
- package/dist/channels/github/scaffold/channel.ts +0 -25
- package/dist/cli/commands/attach.d.ts +0 -108
- package/dist/cli/commands/attach.js +0 -637
- package/dist/cli/commands/fire.d.ts +0 -7
- package/dist/cli/commands/fire.js +0 -38
- package/dist/cli/commands/schedule.d.ts +0 -12
- package/dist/cli/commands/schedule.js +0 -87
- package/dist/github.d.ts +0 -3
- package/dist/github.js +0 -2
- package/dist/scaffold/templates/fastagent.config.mjs +0 -23
- package/dist/schedule/audit.d.ts +0 -18
- package/dist/schedule/audit.js +0 -57
- package/dist/schedule/schedule.d.ts +0 -35
- package/dist/schedule/schedule.js +0 -23
package/dist/channels/serve.js
CHANGED
|
@@ -1,62 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* How a {@link Routes} table becomes a running server: the path rule, dispatch, the
|
|
3
|
-
* boundary, and the node:http binding.
|
|
4
|
-
*
|
|
5
|
-
* Shared ground, not a deployment target — every host in `src/deploy/` runs this same process; what
|
|
6
|
-
* differs between them is process, storage, credentials and deployment (core.md §1), none of it
|
|
7
|
-
* here.
|
|
8
|
-
*
|
|
9
|
-
* Dispatch is a Map lookup rather than a router because a deployment mounts a handful of LITERAL
|
|
10
|
-
* paths. A routing library would answer through a pattern language we do not use, and its extra
|
|
11
|
-
* semantics (decode-before-match, HEAD fallback, wildcard precedence) would have to be predicted by
|
|
12
|
-
* every collision check.
|
|
13
|
-
*
|
|
14
|
-
* The types a channel author or an embedder writes stay pure Fetch — SPEC §11 fixes the gateway
|
|
15
|
-
* contract as `(Request) => Response`, and an embedding app may already run its own framework.
|
|
16
|
-
* `overrideGlobalObjects: false` is part of that: the adapter otherwise swaps
|
|
17
|
-
* `globalThis.Request`/`Response` process-wide, which breaks a channel holding a `Response`
|
|
18
|
-
* captured before mount (it fails `instanceof` in {@link totalFetch} and is answered 500).
|
|
19
|
-
*
|
|
20
|
-
* Post-ACK work (a webhook channel's fire-and-forget turns) runs on this event loop and is lost on
|
|
21
|
-
* shutdown — the accepted tradeoff until durable execution (the K axis) exists.
|
|
2
|
+
* How a {@link Routes} table becomes a running server: the path rule, dispatch, WHO MAY CALL IT FROM A BROWSER, the
|
|
3
|
+
* totality boundary, and the node:http binding.
|
|
22
4
|
*/
|
|
23
5
|
import { serve, getRequestListener } from "@hono/node-server";
|
|
24
6
|
import { log } from "../log.js";
|
|
7
|
+
import { refuseNonJsonBody } from "./body.js";
|
|
25
8
|
import { text } from "./respond.js";
|
|
26
|
-
/**
|
|
27
|
-
|
|
9
|
+
/** Methods whose request carries a body a handler will parse — what the JSON gate applies to. */
|
|
10
|
+
const BODY_METHODS = new Set(["POST", "PUT", "PATCH"]);
|
|
11
|
+
/** Parse a route key: `"METHOD /path"` → `{ method, path }`, or `"/path"` → `{ path }` (any method). */
|
|
28
12
|
export function parseRouteKey(key) {
|
|
29
13
|
const sp = key.indexOf(" ");
|
|
30
14
|
return sp === -1 ? { path: key } : { method: key.slice(0, sp).toUpperCase(), path: key.slice(sp + 1) };
|
|
31
15
|
}
|
|
32
|
-
/**
|
|
33
|
-
* A route key is `"METHOD /path"` or `"/path"`, with a literal path.
|
|
34
|
-
*
|
|
35
|
-
* Every rule here exists because breaking it costs SOMEONE ELSE. Two keys naming one route, a route
|
|
36
|
-
* under a mount, a path a URL rewrites (`/a/../x` and `/x` are one route spelled two ways, while
|
|
37
|
-
* the conflict check compares strings) — in each case a channel goes dark and its author did
|
|
38
|
-
* nothing wrong.
|
|
39
|
-
*
|
|
40
|
-
* Mistakes that only cost their own author a debugging session are NOT policed: `:id` and `*` are
|
|
41
|
-
* literal path characters here, so such a key simply never matches, and an unusual method is the
|
|
42
|
-
* same. Refusing those would trade a working capability for a lecture.
|
|
43
|
-
*/
|
|
16
|
+
/** A route key is `"METHOD /path"` or `"/path"`, with a literal path. */
|
|
44
17
|
export function assertRouteKey(key, describe) {
|
|
45
18
|
const { method, path } = parseRouteKey(key);
|
|
46
19
|
if (method === "")
|
|
47
20
|
throw new Error(describe('a leading space is not a method — write "/path" for any method'));
|
|
48
21
|
if (!path.startsWith("/"))
|
|
49
22
|
throw new Error(describe('must start with "/"'));
|
|
50
|
-
// Asked of `URL` rather than by listing what it rewrites (`?`/`#`, `.`/`..`, `\`, `%2e`).
|
|
51
|
-
// rewrites is unreachable AND compares as a different string, hiding that `/a/../x` and `/x` are
|
|
52
|
-
// one route.
|
|
23
|
+
// Asked of `URL` rather than by listing what it rewrites (`?`/`#`, `.`/`..`, `\`, `%2e`).
|
|
53
24
|
const arrives = new URL(path, "http://x").pathname;
|
|
54
25
|
if (arrives !== path) {
|
|
55
26
|
throw new Error(describe(`is not the path a request would carry — that request arrives as "${arrives}"`));
|
|
56
27
|
}
|
|
57
28
|
}
|
|
58
|
-
/** Do these two keys fight over the same request?
|
|
59
|
-
* with no method answers all of them. */
|
|
29
|
+
/** Do these two keys fight over the same request? */
|
|
60
30
|
export function routeKeysConflict(a, b) {
|
|
61
31
|
const ka = parseRouteKey(a);
|
|
62
32
|
const kb = parseRouteKey(b);
|
|
@@ -64,28 +34,97 @@ export function routeKeysConflict(a, b) {
|
|
|
64
34
|
return false;
|
|
65
35
|
return ka.method === undefined || kb.method === undefined || ka.method === kb.method;
|
|
66
36
|
}
|
|
67
|
-
/** Same status and headers, no content (RFC 9110's HEAD).
|
|
68
|
-
* streaming producer keeps running with no reader. Shared with the control plane, which answers
|
|
69
|
-
* HEAD too. */
|
|
37
|
+
/** Same status and headers, no content (RFC 9110's HEAD). */
|
|
70
38
|
export function withoutBody(res) {
|
|
71
39
|
void res.body?.cancel().catch(() => { });
|
|
72
40
|
return new Response(null, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
73
41
|
}
|
|
74
|
-
/** Is `path` inside `prefix`? Segment-wise, so `/controlled` is not inside `/control`. */
|
|
75
42
|
export function pathUnderPrefix(path, prefix) {
|
|
76
43
|
return path === prefix || path.startsWith(`${prefix}/`);
|
|
77
44
|
}
|
|
78
45
|
/**
|
|
79
|
-
*
|
|
46
|
+
* THE cross-origin policy, in one place, for the routes that authenticate nobody.
|
|
47
|
+
*
|
|
48
|
+
* THIS IS AN API, so the default is an API's: `*`, unconditionally. Origin is not access control — what makes any
|
|
49
|
+
* API safe to call from a page is a credential the page does not have, which makes the caller's origin irrelevant.
|
|
50
|
+
* We have no credential, and the boundary that replaces it — who may reach this port — belongs to the deployment,
|
|
51
|
+
* the same rule that removed the bearer token. Deciding for the operator from a signal this process can observe
|
|
52
|
+
* (which address it bound) is not that rule: a wildcard bind is what a container needs, not something the operator
|
|
53
|
+
* said, and a loopback one is `dev`'s default, not a claim about who may call.
|
|
80
54
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
55
|
+
* WHAT `*` GRANTS, stated because it is the default and nobody opts into it: any page the user's browser visits can
|
|
56
|
+
* call this port cross-origin and read the reply — a turn with the agent's full tool authority, and with
|
|
57
|
+
* `sessionControl: true`, reading and deleting sessions. On a published port that is nothing an attacker could not
|
|
58
|
+
* already curl. On a loopback `dev` serve it is the whole attack path, and this is the posture the decision costs:
|
|
59
|
+
* `announceControl` says so at every boot, and `http.cors` is the one way to take it back.
|
|
84
60
|
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
61
|
+
* `allow` is `http.cors`, and when it is set it REPLACES the default rather than adding to it: `["*"]` is the
|
|
62
|
+
* default said out loud, a list of origins is the narrowing.
|
|
63
|
+
*
|
|
64
|
+
* An origin that is not allowed is simply not ANSWERED — no headers, no refusal. That is safe only because a route
|
|
65
|
+
* that authenticates nobody also refuses a body that is not `application/json` (channels/body.ts): without that, a
|
|
66
|
+
* cross-origin `text/plain` POST is a CORS simple request, sent with no preflight, and withholding the headers
|
|
67
|
+
* would only stop the page from reading a turn that had already run.
|
|
87
68
|
*/
|
|
88
|
-
|
|
69
|
+
function allowedOrigin(origin, allow) {
|
|
70
|
+
if (allow.length === 0)
|
|
71
|
+
return "*";
|
|
72
|
+
return allow.includes("*") ? "*" : allow.includes(origin) ? origin : undefined;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Refuse a cross-origin allow-list that cannot match anything.
|
|
76
|
+
*
|
|
77
|
+
* ONE check for BOTH ways in: `http.cors` in a config file, and `corsOrigins` handed to `mountAgentService` by an
|
|
78
|
+
* embedder. `allowedOrigin` compares exact strings, so `"https://app.example.com/"` — one trailing slash — never
|
|
79
|
+
* matches and the front end is refused with nothing pointing at the rule. A rule that silently matches nothing is
|
|
80
|
+
* worse than one that refuses to load.
|
|
81
|
+
*/
|
|
82
|
+
export function assertCorsOrigins(origins, where) {
|
|
83
|
+
if (!Array.isArray(origins) || origins.some((o) => typeof o !== "string" || o === "")) {
|
|
84
|
+
throw new Error(`${where} must be an array of origin strings (e.g. ["https://app.example.com"])`);
|
|
85
|
+
}
|
|
86
|
+
// An EMPTY list is the one spelling that means the OPPOSITE of what it looks like. `allowedOrigin` reads "nothing
|
|
87
|
+
// configured" as the `*` default and cannot tell that apart from a list someone wrote as empty, so `http.cors: []`
|
|
88
|
+
// — the obvious way to write "no page may call this" — would grant the widest policy there is, and silence the
|
|
89
|
+
// boot warning that names it (`announceControl` treats a set list as the operator having chosen the origins).
|
|
90
|
+
if (origins.length === 0) {
|
|
91
|
+
throw new Error(`${where} is empty — list at least one origin, or write ["*"] to say the default out loud`);
|
|
92
|
+
}
|
|
93
|
+
for (const origin of origins) {
|
|
94
|
+
if (origin === "*")
|
|
95
|
+
continue;
|
|
96
|
+
let parsed;
|
|
97
|
+
try {
|
|
98
|
+
parsed = new URL(origin);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// `new URL` throwing IS the answer here ("not a URL at all"); rethrown as the rule that cannot load.
|
|
102
|
+
throw new Error(`${where} entry ${JSON.stringify(origin)} is not an origin ("https://host[:port]")`);
|
|
103
|
+
}
|
|
104
|
+
// An ORIGIN, not a URL with a path: `new URL(...).origin` is what a browser sends.
|
|
105
|
+
if (parsed.origin !== origin) {
|
|
106
|
+
throw new Error(`${where} entry ${JSON.stringify(origin)} is not the origin a browser sends — use ${JSON.stringify(parsed.origin)}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Compose the routes that AUTHENTICATE NOBODY, the self-verifying channel routes, and the {@link PrefixMount}s
|
|
112
|
+
* into one handler.
|
|
113
|
+
*
|
|
114
|
+
* That is the axis, not authorship. Two things follow from it and nothing else does: whether a browser may drive
|
|
115
|
+
* the route, and whether its body must declare JSON. A channel route is exempt from both because it checks its
|
|
116
|
+
* platform's signature inside itself — a Telegram webhook is public on purpose. Ours check nothing, so the router
|
|
117
|
+
* checks for them.
|
|
118
|
+
*
|
|
119
|
+
* TWO TABLES, not one table plus a list of which keys are which, so the three readers (browser reachability, the JSON
|
|
120
|
+
* body gate, the startup report) cannot answer differently.
|
|
121
|
+
*
|
|
122
|
+
* CAVEAT, stated because we cannot enforce it: a CUSTOM channel that verifies nothing lands in `selfVerifying`
|
|
123
|
+
* anyway and gets neither guard. That is decision B (docs/design/session-control.md §14) and it belongs to the
|
|
124
|
+
* channel's author, who owns the credential the platform issued — but it is an assumption here, not a property.
|
|
125
|
+
*/
|
|
126
|
+
export function router(surface) {
|
|
127
|
+
const { unverified = {}, selfVerifying = {}, mounts = [], corsOrigins = [] } = surface;
|
|
89
128
|
for (const [i, mount] of mounts.entries()) {
|
|
90
129
|
assertRouteKey(mount.prefix, (problem) => `mount prefix "${mount.prefix}" is invalid — ${problem}`);
|
|
91
130
|
if (mount.prefix === "/") {
|
|
@@ -103,7 +142,8 @@ export function router(routes, mounts = []) {
|
|
|
103
142
|
}
|
|
104
143
|
const byKey = new Map();
|
|
105
144
|
const paths = new Set();
|
|
106
|
-
|
|
145
|
+
const unguarded = new Set();
|
|
146
|
+
for (const [key, handler] of [...Object.entries(unverified), ...Object.entries(selfVerifying)]) {
|
|
107
147
|
assertRouteKey(key, (problem) => `route "${key}" is not a valid route key — ${problem}`);
|
|
108
148
|
const { path } = parseRouteKey(key);
|
|
109
149
|
for (const mount of mounts) {
|
|
@@ -115,16 +155,52 @@ export function router(routes, mounts = []) {
|
|
|
115
155
|
if (shadowed) {
|
|
116
156
|
throw new Error(`route "${key}" conflicts with "${shadowed}" — one of them would never receive a request`);
|
|
117
157
|
}
|
|
118
|
-
// Stored normalised: `parseRouteKey` upper-cases the method, so `"get /x"` validates under
|
|
119
|
-
//
|
|
158
|
+
// Stored normalised: `parseRouteKey` upper-cases the method, so `"get /x"` validates under `GET` and would
|
|
159
|
+
// otherwise be looked up under a name nothing stores.
|
|
120
160
|
const { method } = parseRouteKey(key);
|
|
121
|
-
|
|
161
|
+
const normalised = method ? `${method} ${path}` : path;
|
|
162
|
+
byKey.set(normalised, handler);
|
|
122
163
|
paths.add(path);
|
|
164
|
+
if (key in unverified)
|
|
165
|
+
unguarded.add(normalised);
|
|
123
166
|
}
|
|
167
|
+
/** Does this route authenticate nobody? Every mount does (the control plane), plus what we registered ourselves. */
|
|
168
|
+
const authenticatesNobody = (method, path) => mounts.some((mount) => pathUnderPrefix(path, mount.prefix)) ||
|
|
169
|
+
unguarded.has(`${method} ${path}`) ||
|
|
170
|
+
unguarded.has(path) ||
|
|
171
|
+
// The router answers HEAD from a GET route, so the question follows it.
|
|
172
|
+
(method === "HEAD" && unguarded.has(`GET ${path}`));
|
|
124
173
|
return (req) => {
|
|
125
174
|
// `URL` normalises the path (`/a/../x` → `/x`) and drops query/fragment.
|
|
126
175
|
const path = new URL(req.url).pathname;
|
|
176
|
+
const origin = req.headers.get("origin");
|
|
177
|
+
// A preflight asks ABOUT a method; ownership is that method's, not `OPTIONS`'s (nothing registers OPTIONS).
|
|
178
|
+
const asking = req.method === "OPTIONS"
|
|
179
|
+
? (req.headers.get("access-control-request-method")?.toUpperCase() ?? "OPTIONS")
|
|
180
|
+
: req.method;
|
|
181
|
+
// A BROWSER request against a route that authenticates nobody. A non-browser client sends no `Origin` and is
|
|
182
|
+
// untouched by any of this.
|
|
183
|
+
const owned = authenticatesNobody(asking, path);
|
|
184
|
+
const fromPage = origin !== null && owned;
|
|
185
|
+
// Decided BEFORE dispatch, so a 404, a 405 and a handler's own reply all leave with the same verdict — a browser
|
|
186
|
+
// that cannot read the 405 gets an opaque network error instead of the reason.
|
|
187
|
+
const cors = fromPage ? allowedOrigin(origin, corsOrigins) : undefined;
|
|
127
188
|
const answer = () => {
|
|
189
|
+
// The gate that makes "an origin we do not allow is simply not answered" safe: a POST carrying `text/plain`
|
|
190
|
+
// is a CORS simple request, sent with no preflight at all, so a route that authenticates nobody must refuse
|
|
191
|
+
// the body itself. HERE, over the whole unguarded surface, rather than inside each handler — the next route
|
|
192
|
+
// we add is covered by existing. A self-verifying channel is exempt: Slack posts urlencoded, and a page
|
|
193
|
+
// cannot forge its signature anyway.
|
|
194
|
+
if (owned && BODY_METHODS.has(req.method)) {
|
|
195
|
+
const wrongType = refuseNonJsonBody(req);
|
|
196
|
+
if (wrongType)
|
|
197
|
+
return wrongType;
|
|
198
|
+
}
|
|
199
|
+
// The preflight is the ROUTER's to answer: it names a method the route table does not register, so leaving it
|
|
200
|
+
// to the routes below is a 405 with no CORS headers, which is a browser client that cannot call a route that
|
|
201
|
+
// works.
|
|
202
|
+
if (req.method === "OPTIONS" && cors !== undefined)
|
|
203
|
+
return new Response(null, { status: 204 });
|
|
128
204
|
for (const mount of mounts)
|
|
129
205
|
if (pathUnderPrefix(path, mount.prefix))
|
|
130
206
|
return mount.handler(req);
|
|
@@ -138,22 +214,36 @@ export function router(routes, mounts = []) {
|
|
|
138
214
|
}
|
|
139
215
|
return paths.has(path) ? text("method not allowed\n", 405) : text("not found\n", 404);
|
|
140
216
|
};
|
|
141
|
-
|
|
142
|
-
|
|
217
|
+
/**
|
|
218
|
+
* ONE exit, so the HEAD rule and the CORS verdict hold for every reply — a mount's, a route's, the GET
|
|
219
|
+
* fallback's, and the 404/405 this router writes itself.
|
|
220
|
+
*/
|
|
221
|
+
const finish = (res) => {
|
|
222
|
+
const out = req.method === "HEAD" ? withoutBody(res) : res;
|
|
223
|
+
// `vary` whether or not the origin was allowed: the answer DEPENDS on the request's origin either way, and a
|
|
224
|
+
// cache that does not know it would serve one caller's verdict to another.
|
|
225
|
+
if (owned)
|
|
226
|
+
out.headers.append("vary", "origin");
|
|
227
|
+
if (cors === undefined)
|
|
228
|
+
return out;
|
|
229
|
+
out.headers.set("access-control-allow-origin", cors);
|
|
230
|
+
// Echoed for the same reason the methods are: what a caller needs is not ours to enumerate. A gateway in front
|
|
231
|
+
// of this port demands its own header (`connectSessionControl({ fetchFn })` is how a client sends one), and a
|
|
232
|
+
// fixed list turns that preflight into a 204 the browser then refuses to act on. The default names the two a
|
|
233
|
+
// client of ours always needs, for a preflight that asked about nothing.
|
|
234
|
+
out.headers.set("access-control-allow-headers", req.headers.get("access-control-request-headers") ?? "authorization, content-type");
|
|
235
|
+
// The method the preflight ASKED about, not a table lookup: a mount owns a prefix and does not publish which
|
|
236
|
+
// methods each path under it serves. Naming one the path does not serve costs nothing now that the 405 it
|
|
237
|
+
// produces carries these same headers.
|
|
238
|
+
const asked = req.headers.get("access-control-request-method")?.toUpperCase();
|
|
239
|
+
out.headers.set("access-control-allow-methods", [...new Set([asked, "OPTIONS"].filter(Boolean))].join(", "));
|
|
240
|
+
return out;
|
|
241
|
+
};
|
|
143
242
|
const res = answer();
|
|
144
|
-
|
|
145
|
-
return res;
|
|
146
|
-
return res instanceof Promise ? res.then(withoutBody) : withoutBody(res);
|
|
243
|
+
return res instanceof Promise ? res.then(finish) : finish(res);
|
|
147
244
|
};
|
|
148
245
|
}
|
|
149
|
-
/**
|
|
150
|
-
* The totality boundary every serving path shares. `loadChannels` imports arbitrary author code, so
|
|
151
|
-
* a channel that throws — or forgets to return — must become a logged 500 rather than escape as an
|
|
152
|
-
* unhandled rejection. Both `nodeListener` and `serveNode` route through it.
|
|
153
|
-
*
|
|
154
|
-
* The message stays internal: an adapter's default error page would echo exception text (a stack, a
|
|
155
|
-
* path, a key inside an error string) to whoever made the request. Visibility is the LOG's job.
|
|
156
|
-
*/
|
|
246
|
+
/** The totality boundary every serving path shares. */
|
|
157
247
|
function totalFetch(handler) {
|
|
158
248
|
return async (req) => {
|
|
159
249
|
try {
|
|
@@ -168,30 +258,15 @@ function totalFetch(handler) {
|
|
|
168
258
|
}
|
|
169
259
|
};
|
|
170
260
|
}
|
|
171
|
-
/**
|
|
172
|
-
* Has upstream middleware already drained the request body?
|
|
173
|
-
*
|
|
174
|
-
* Node's `req` is one-shot, so a body parser mounted ahead of this route consumes it — a property of
|
|
175
|
-
* the Node/Fetch seam, not of this adapter. What is avoidable is the diagnosis: undici answers
|
|
176
|
-
* `TypeError: Body is unusable`, naming neither the cause nor the fix, and for a webhook channel
|
|
177
|
-
* that reads as "the integration is broken and the platform keeps retrying".
|
|
178
|
-
*
|
|
179
|
-
* Answers only when CERTAIN — a positive `content-length`. `readableEnded` alone is true for any
|
|
180
|
-
* request something upstream merely touched, and an empty chunked body is legal, arrives drained,
|
|
181
|
-
* and is indistinguishable from an eaten one. Guessing there rejects valid requests, making this
|
|
182
|
-
* guard the outage it explains.
|
|
183
|
-
*/
|
|
261
|
+
/** Has upstream middleware already drained the request body? */
|
|
184
262
|
function bodyAlreadyRead(req) {
|
|
185
263
|
const length = Number(req.headers["content-length"]);
|
|
186
264
|
return Number.isFinite(length) && length > 0 && req.readableEnded;
|
|
187
265
|
}
|
|
188
|
-
/**
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
|
|
192
|
-
* handler may answer synchronously, and `router()` returns exactly that. Requiring a Promise here
|
|
193
|
-
* made the most natural mount — `nodeListener(router(routes))`, the whole agent on the app's own
|
|
194
|
-
* server — a type error. */
|
|
266
|
+
/**
|
|
267
|
+
* The node:http adapter for a Fetch handler — the embedded server uses it, and an embedder mounting fastagent on its
|
|
268
|
+
* OWN node:http server can too.
|
|
269
|
+
*/
|
|
195
270
|
export function nodeListener(handler) {
|
|
196
271
|
const listener = getRequestListener(totalFetch(handler), { overrideGlobalObjects: false });
|
|
197
272
|
return (req, res) => {
|
|
@@ -206,12 +281,6 @@ export function nodeListener(handler) {
|
|
|
206
281
|
listener(req, res);
|
|
207
282
|
};
|
|
208
283
|
}
|
|
209
|
-
/**
|
|
210
|
-
* Serve `handler` on a Node HTTP server. Thin mechanism: bind, report the port, let the caller stop
|
|
211
|
-
* accepting or force-close active connections — no logging/signals/exit (the CLI owns those).
|
|
212
|
-
* `listening` resolves with the bound port (useful for port 0) or rejects on a bind error.
|
|
213
|
-
* `host` is the bind address; unset means all interfaces (what containers need).
|
|
214
|
-
*/
|
|
215
284
|
export function serveNode(handler, options) {
|
|
216
285
|
let onListening;
|
|
217
286
|
let onBindError;
|
|
@@ -219,20 +288,15 @@ export function serveNode(handler, options) {
|
|
|
219
288
|
onListening = resolve;
|
|
220
289
|
onBindError = reject;
|
|
221
290
|
});
|
|
222
|
-
// serve() types its return as the union of every server it CAN build (incl. http2).
|
|
223
|
-
// a createServer/http2 option, so it is always node:http's Server — the one carrying
|
|
224
|
-
// closeAllConnections, which the caller-owned force-close depends on.
|
|
291
|
+
// serve() types its return as the union of every server it CAN build (incl. http2).
|
|
225
292
|
const server = serve({
|
|
226
293
|
fetch: totalFetch(handler),
|
|
227
294
|
overrideGlobalObjects: false,
|
|
228
295
|
port: options.port,
|
|
229
296
|
...(options.host !== undefined ? { hostname: options.host } : {}),
|
|
230
297
|
}, (info) => {
|
|
231
|
-
// Detach before resolving: this listener answers the BIND, and leaving it attached would let
|
|
232
|
-
//
|
|
233
|
-
// anywhere. Detached, an error after bind is an unhandled 'error' event, which is loud.
|
|
234
|
-
// (Not reachable from a test without forging an event on a server this function does not
|
|
235
|
-
// expose; the bind-failure half below is covered.)
|
|
298
|
+
// Detach before resolving: this listener answers the BIND, and leaving it attached would let a later runtime
|
|
299
|
+
// error call reject() on a settled promise.
|
|
236
300
|
server.off("error", onBindError);
|
|
237
301
|
onListening(info.port);
|
|
238
302
|
});
|
|
@@ -10,37 +10,16 @@ interface SlackErrorShape {
|
|
|
10
10
|
export declare class SlackConfigApiError extends Error {
|
|
11
11
|
readonly code: string;
|
|
12
12
|
readonly status: number;
|
|
13
|
-
/** Kept, not just formatted into the message
|
|
14
|
-
* not-yet-reachable URL from a malformed one, and a message match cannot see the difference. */
|
|
13
|
+
/** Kept, not just formatted into the message. */
|
|
15
14
|
readonly errors: {
|
|
16
15
|
message?: string;
|
|
17
16
|
pointer?: string;
|
|
18
17
|
}[];
|
|
19
18
|
constructor(method: string, status: number, data: SlackErrorShape, fallback: string);
|
|
20
19
|
}
|
|
21
|
-
/**
|
|
22
|
-
* Whether a manifest call failed because Slack could not verify the `request_url` it carries — the
|
|
23
|
-
* platform's own readiness verdict on a freshly minted public URL, and the reason no local `/health`
|
|
24
|
-
* poll precedes these calls (#421). Slack validates the manifest BEFORE acting on it, so a call that
|
|
25
|
-
* ends here changed nothing and is safe to repeat.
|
|
26
|
-
*
|
|
27
|
-
* Read from the rejected FIELD (`invalid_manifest` + {@link rejectsRequestUrl}), not from `request_url`
|
|
28
|
-
* appearing anywhere in the formatted string: that also matched errors which merely mention the field.
|
|
29
|
-
*
|
|
30
|
-
* KNOWN IMPRECISION: this cannot separate "not reachable yet" from "this URL is unacceptable" — both
|
|
31
|
-
* arrive the same way, and the wording that would tell them apart has not been observed against the
|
|
32
|
-
* real API, so guessing at it would risk never retrying at all. A permanently bad URL therefore costs
|
|
33
|
-
* the full budget before it is reported. Every URL here is a tunnel's or a host's, so it is well-formed
|
|
34
|
-
* https by construction; narrow this the day a real rejection is captured.
|
|
35
|
-
*/
|
|
20
|
+
/** Whether a manifest call failed because Slack could not verify the `request_url` it carries. */
|
|
36
21
|
export declare function isSlackRequestUrlUnverified(error: unknown): boolean;
|
|
37
|
-
/**
|
|
38
|
-
* Whether a manifest write was refused because the app has token rotation on, which Slack never lets
|
|
39
|
-
* off again — the refusal every app created by a release up to 0.20 meets, since this release sends
|
|
40
|
-
* `token_rotation_enabled: false`. Slack documents field rejections as `invalid_manifest` plus an
|
|
41
|
-
* `errors[]` of message + pointer; the bare code was what a real refusal once showed. Both shapes are
|
|
42
|
-
* read: a miss here sends the operator to repair configuration tokens that are not the problem.
|
|
43
|
-
*/
|
|
22
|
+
/** Whether a manifest write was refused because the app has token rotation on, which Slack never lets off again. */
|
|
44
23
|
export declare function isSlackRotationLocked(error: unknown): boolean;
|
|
45
24
|
/** The upgrade every rotating-app refusal points at. */
|
|
46
25
|
export declare const SLACK_ROTATING_APP_UPGRADE = "this app has token rotation on, which this release no longer uses and Slack cannot turn off \u2014 create a new app (docs/slack.md \u2192 \"Upgrading from a rotating-token app\")";
|
|
@@ -51,9 +30,7 @@ export interface SlackAppCredentials {
|
|
|
51
30
|
signingSecret: string;
|
|
52
31
|
oauthAuthorizeUrl?: string;
|
|
53
32
|
}
|
|
54
|
-
/** Single-attempt BY DESIGN: an ambiguous response may already have created the app, so this never
|
|
55
|
-
* retries itself. The one repeatable failure — a `request_url` Slack could not verify, which it
|
|
56
|
-
* rejects before creating anything — is retried by the caller ({@link isSlackRequestUrlUnverified}). */
|
|
33
|
+
/** Single-attempt BY DESIGN: an ambiguous response may already have created the app, so this never retries itself. */
|
|
57
34
|
export declare function createSlackApp(configToken: string, manifest: SlackAppManifest, options?: {
|
|
58
35
|
apiBaseUrl?: string;
|
|
59
36
|
fetch?: typeof fetch;
|
|
@@ -1,19 +1,12 @@
|
|
|
1
1
|
const SLACK_API = "https://slack.com/api";
|
|
2
2
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
3
|
-
/** One `errors[]` entry that means "Slack could not verify this URL", by either signal it may carry.
|
|
4
|
-
* The pointer is the precise one but an INFERENCE — the only reply captured from the real API is the
|
|
5
|
-
* PERMANENT shape error, which reports on the parent pointer (`/settings/event_subscriptions`). If the
|
|
6
|
-
* unverifiable case reports there too, a pointer-only check would retry zero times and gate the deploy
|
|
7
|
-
* immediately, which is the failure this whole path exists to prevent. So a message about VERIFICATION
|
|
8
|
-
* (challenge wording included) counts as well, while one that merely names the field does not — the
|
|
9
|
-
* captured permanent error says "Event Subscription requires a Request URL". */
|
|
3
|
+
/** One `errors[]` entry that means "Slack could not verify this URL", by either signal it may carry. */
|
|
10
4
|
const rejectsRequestUrl = (e) => e.pointer?.endsWith("/request_url") === true ||
|
|
11
5
|
(/request.?url/i.test(e.message ?? "") && /verif|challenge/i.test(e.message ?? ""));
|
|
12
6
|
export class SlackConfigApiError extends Error {
|
|
13
7
|
code;
|
|
14
8
|
status;
|
|
15
|
-
/** Kept, not just formatted into the message
|
|
16
|
-
* not-yet-reachable URL from a malformed one, and a message match cannot see the difference. */
|
|
9
|
+
/** Kept, not just formatted into the message. */
|
|
17
10
|
errors;
|
|
18
11
|
constructor(method, status, data, fallback) {
|
|
19
12
|
const code = data.error ?? fallback;
|
|
@@ -27,31 +20,11 @@ export class SlackConfigApiError extends Error {
|
|
|
27
20
|
this.errors = data.errors ?? [];
|
|
28
21
|
}
|
|
29
22
|
}
|
|
30
|
-
/**
|
|
31
|
-
* Whether a manifest call failed because Slack could not verify the `request_url` it carries — the
|
|
32
|
-
* platform's own readiness verdict on a freshly minted public URL, and the reason no local `/health`
|
|
33
|
-
* poll precedes these calls (#421). Slack validates the manifest BEFORE acting on it, so a call that
|
|
34
|
-
* ends here changed nothing and is safe to repeat.
|
|
35
|
-
*
|
|
36
|
-
* Read from the rejected FIELD (`invalid_manifest` + {@link rejectsRequestUrl}), not from `request_url`
|
|
37
|
-
* appearing anywhere in the formatted string: that also matched errors which merely mention the field.
|
|
38
|
-
*
|
|
39
|
-
* KNOWN IMPRECISION: this cannot separate "not reachable yet" from "this URL is unacceptable" — both
|
|
40
|
-
* arrive the same way, and the wording that would tell them apart has not been observed against the
|
|
41
|
-
* real API, so guessing at it would risk never retrying at all. A permanently bad URL therefore costs
|
|
42
|
-
* the full budget before it is reported. Every URL here is a tunnel's or a host's, so it is well-formed
|
|
43
|
-
* https by construction; narrow this the day a real rejection is captured.
|
|
44
|
-
*/
|
|
23
|
+
/** Whether a manifest call failed because Slack could not verify the `request_url` it carries. */
|
|
45
24
|
export function isSlackRequestUrlUnverified(error) {
|
|
46
25
|
return (error instanceof SlackConfigApiError && error.code === "invalid_manifest" && error.errors.some(rejectsRequestUrl));
|
|
47
26
|
}
|
|
48
|
-
/**
|
|
49
|
-
* Whether a manifest write was refused because the app has token rotation on, which Slack never lets
|
|
50
|
-
* off again — the refusal every app created by a release up to 0.20 meets, since this release sends
|
|
51
|
-
* `token_rotation_enabled: false`. Slack documents field rejections as `invalid_manifest` plus an
|
|
52
|
-
* `errors[]` of message + pointer; the bare code was what a real refusal once showed. Both shapes are
|
|
53
|
-
* read: a miss here sends the operator to repair configuration tokens that are not the problem.
|
|
54
|
-
*/
|
|
27
|
+
/** Whether a manifest write was refused because the app has token rotation on, which Slack never lets off again. */
|
|
55
28
|
export function isSlackRotationLocked(error) {
|
|
56
29
|
if (!(error instanceof SlackConfigApiError))
|
|
57
30
|
return false;
|
|
@@ -91,9 +64,7 @@ async function slackJson(method, body, options = {}) {
|
|
|
91
64
|
}
|
|
92
65
|
return data;
|
|
93
66
|
}
|
|
94
|
-
/** Single-attempt BY DESIGN: an ambiguous response may already have created the app, so this never
|
|
95
|
-
* retries itself. The one repeatable failure — a `request_url` Slack could not verify, which it
|
|
96
|
-
* rejects before creating anything — is retried by the caller ({@link isSlackRequestUrlUnverified}). */
|
|
67
|
+
/** Single-attempt BY DESIGN: an ambiguous response may already have created the app, so this never retries itself. */
|
|
97
68
|
export async function createSlackApp(configToken, manifest, options = {}) {
|
|
98
69
|
const data = await slackJson("apps.manifest.create", { manifest: JSON.stringify(manifest) }, { ...options, token: configToken });
|
|
99
70
|
const credentials = data.credentials;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Slack's half of the shared context buffer (mechanics + consume protocol: ../kit/context-buffer.ts):
|
|
3
|
-
*
|
|
4
|
-
* unsummoned Slack discussion folded into the next answered turn in the same place.
|
|
2
|
+
* Slack's half of the shared context buffer (mechanics + consume protocol: ../kit/context-buffer.ts): the entry shape,
|
|
3
|
+
* its fold-line rendering, and buffered-file selection.
|
|
5
4
|
*/
|
|
6
5
|
import { type ContextBuffer } from "../kit/context-buffer.ts";
|
|
7
6
|
export interface SlackBufferEntry {
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Slack's half of the shared context buffer (mechanics + consume protocol: ../kit/context-buffer.ts):
|
|
3
|
-
*
|
|
4
|
-
* unsummoned Slack discussion folded into the next answered turn in the same place.
|
|
2
|
+
* Slack's half of the shared context buffer (mechanics + consume protocol: ../kit/context-buffer.ts): the entry shape,
|
|
3
|
+
* its fold-line rendering, and buffered-file selection.
|
|
5
4
|
*/
|
|
6
|
-
import {
|
|
5
|
+
import { capBufferedRefs, createContextBuffer as createGenericContextBuffer, } from "../kit/context-buffer.js";
|
|
7
6
|
function bufferLine(entry) {
|
|
8
7
|
const meta = [`msg ${entry.messageId}`, entry.replyTo ? `thread root ${entry.replyTo}` : undefined]
|
|
9
8
|
.filter(Boolean)
|
|
@@ -21,7 +20,8 @@ export function collectSlackBufferedFiles(consumed, primaryIds) {
|
|
|
21
20
|
files.push({ id, from: entry.sender, messageId: entry.messageId });
|
|
22
21
|
}
|
|
23
22
|
}
|
|
24
|
-
|
|
23
|
+
const capped = capBufferedRefs(files);
|
|
24
|
+
return { files: capped.kept, skipped: capped.skipped };
|
|
25
25
|
}
|
|
26
26
|
function isEntry(value) {
|
|
27
27
|
const entry = value;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Resolve Slack file IDs at dequeue, then stream one engine-neutral Agent turn. */
|
|
2
2
|
import type { Agent, AgentEvent } from "../../agent.ts";
|
|
3
|
+
import type * as Stream from "effect/Stream";
|
|
4
|
+
import type { PortFailure } from "../../effect-port.ts";
|
|
3
5
|
import { type BusyRetry } from "../kit/invoke-turn-kit.ts";
|
|
4
6
|
import type { SlackBufferedFileRef } from "./context-buffer.ts";
|
|
5
7
|
import { type SlackApi } from "./slack-api.ts";
|
|
@@ -16,4 +18,4 @@ export interface SlackTurnAttachments {
|
|
|
16
18
|
skipped: number;
|
|
17
19
|
};
|
|
18
20
|
}
|
|
19
|
-
export declare function
|
|
21
|
+
export declare function slackTurnStream(agent: Agent, session: string, text: string, transport: SlackTurnTransport, attachments: SlackTurnAttachments, busyRetry?: BusyRetry): Stream.Stream<AgentEvent, PortFailure>;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../kit/invoke-turn-kit.js";
|
|
1
|
+
import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, loadBackground, missingAttachmentsNote, turnStream, } from "../kit/invoke-turn-kit.js";
|
|
3
2
|
import { SlackApiError } from "./slack-api.js";
|
|
4
3
|
const MARKDOWN_INSTRUCTION = "\n\n(Format your reply as standard Markdown. Slack renders it natively. Do not use HTML or Slack control-mention syntax such as <!here>, <!channel>, or <!everyone>.)";
|
|
5
4
|
async function resolveFile(transport, fileId) {
|
|
@@ -20,25 +19,17 @@ async function resolveInputs(transport, attachments) {
|
|
|
20
19
|
}
|
|
21
20
|
const backgroundImages = [];
|
|
22
21
|
const backgroundFiles = [];
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
backgroundImages.push({ image: resolved.image, ref });
|
|
33
|
-
if (resolved.file)
|
|
34
|
-
backgroundFiles.push({ file: resolved.file, ref });
|
|
35
|
-
}
|
|
36
|
-
else {
|
|
37
|
-
lost++;
|
|
38
|
-
log.warn(`${transport.label} could not load an earlier (buffered) Slack file: ${String(result.reason)}`);
|
|
39
|
-
}
|
|
22
|
+
const background = await loadBackground(attachments.buffered.files, (ref) => resolveFile(transport, ref.id), {
|
|
23
|
+
label: transport.label,
|
|
24
|
+
what: "Slack file",
|
|
25
|
+
});
|
|
26
|
+
for (const { ref, value } of background.loaded) {
|
|
27
|
+
if (value.image)
|
|
28
|
+
backgroundImages.push({ image: value.image, ref });
|
|
29
|
+
if (value.file)
|
|
30
|
+
backgroundFiles.push({ file: value.file, ref });
|
|
40
31
|
}
|
|
41
|
-
const missingNote = missingAttachmentsNote(lost + attachments.buffered.skipped);
|
|
32
|
+
const missingNote = missingAttachmentsNote(background.lost + attachments.buffered.skipped);
|
|
42
33
|
const imageManifest = backgroundImagesManifest(images.length, backgroundImages.map(({ ref }) => ref));
|
|
43
34
|
const allFiles = [
|
|
44
35
|
...files,
|
|
@@ -53,18 +44,18 @@ async function resolveInputs(transport, attachments) {
|
|
|
53
44
|
promptSuffix: `${missingNote}${imageManifest}${attachedFilesManifest(allFiles)}`,
|
|
54
45
|
};
|
|
55
46
|
}
|
|
56
|
-
export
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
47
|
+
export function slackTurnStream(agent, session, text, transport, attachments, busyRetry = DEFAULT_BUSY_RETRY) {
|
|
48
|
+
return turnStream({
|
|
49
|
+
agent,
|
|
50
|
+
label: transport.label,
|
|
51
|
+
busyRetry,
|
|
52
|
+
resolve: () => resolveInputs(transport, attachments),
|
|
53
|
+
turn: (resolved) => ({
|
|
54
|
+
scope: { session },
|
|
55
|
+
prompt: { text: `${text}${resolved.promptSuffix}${MARKDOWN_INSTRUCTION}`, images: resolved.images },
|
|
56
|
+
}),
|
|
57
|
+
// Slack says which failures are transient; anything else (a deleted file, a missing scope) will
|
|
58
|
+
// fail the same way on redelivery.
|
|
59
|
+
retryableLoadFailure: (cause) => cause instanceof SlackApiError && (cause.status === 0 || cause.status === 429 || cause.status >= 500),
|
|
60
|
+
});
|
|
70
61
|
}
|
|
@@ -64,9 +64,7 @@ export function buildSlackManifest(input) {
|
|
|
64
64
|
org_deploy_enabled: false,
|
|
65
65
|
socket_mode_enabled: false,
|
|
66
66
|
// Off, and Slack makes the alternative permanent: an app with rotation on can never turn it off
|
|
67
|
-
// (`cannot_disable_once_enabled`, verified against the API).
|
|
68
|
-
// and the client secret to every host beside the access token — the same blast radius as one
|
|
69
|
-
// long-lived bot token, plus a 12-hour refresh lineage to keep single and durable.
|
|
67
|
+
// (`cannot_disable_once_enabled`, verified against the API).
|
|
70
68
|
token_rotation_enabled: false,
|
|
71
69
|
},
|
|
72
70
|
};
|