pilotswarm 0.0.1 → 0.4.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 +37 -1
- package/mcp/README.md +484 -0
- package/mcp/dist/bin/pilotswarm-mcp.d.ts +3 -0
- package/mcp/dist/bin/pilotswarm-mcp.d.ts.map +1 -0
- package/mcp/dist/bin/pilotswarm-mcp.js +367 -0
- package/mcp/dist/bin/pilotswarm-mcp.js.map +1 -0
- package/mcp/dist/src/auth.d.ts +7 -0
- package/mcp/dist/src/auth.d.ts.map +1 -0
- package/mcp/dist/src/auth.js +99 -0
- package/mcp/dist/src/auth.js.map +1 -0
- package/mcp/dist/src/context.d.ts +48 -0
- package/mcp/dist/src/context.d.ts.map +1 -0
- package/mcp/dist/src/context.js +83 -0
- package/mcp/dist/src/context.js.map +1 -0
- package/mcp/dist/src/index.d.ts +4 -0
- package/mcp/dist/src/index.d.ts.map +1 -0
- package/mcp/dist/src/index.js +3 -0
- package/mcp/dist/src/index.js.map +1 -0
- package/mcp/dist/src/prompts/skills.d.ts +4 -0
- package/mcp/dist/src/prompts/skills.d.ts.map +1 -0
- package/mcp/dist/src/prompts/skills.js +11 -0
- package/mcp/dist/src/prompts/skills.js.map +1 -0
- package/mcp/dist/src/resources/agents.d.ts +4 -0
- package/mcp/dist/src/resources/agents.d.ts.map +1 -0
- package/mcp/dist/src/resources/agents.js +64 -0
- package/mcp/dist/src/resources/agents.js.map +1 -0
- package/mcp/dist/src/resources/facts.d.ts +4 -0
- package/mcp/dist/src/resources/facts.d.ts.map +1 -0
- package/mcp/dist/src/resources/facts.js +125 -0
- package/mcp/dist/src/resources/facts.js.map +1 -0
- package/mcp/dist/src/resources/models.d.ts +4 -0
- package/mcp/dist/src/resources/models.d.ts.map +1 -0
- package/mcp/dist/src/resources/models.js +43 -0
- package/mcp/dist/src/resources/models.js.map +1 -0
- package/mcp/dist/src/resources/sessions.d.ts +4 -0
- package/mcp/dist/src/resources/sessions.d.ts.map +1 -0
- package/mcp/dist/src/resources/sessions.js +190 -0
- package/mcp/dist/src/resources/sessions.js.map +1 -0
- package/mcp/dist/src/resources/subscriptions.d.ts +9 -0
- package/mcp/dist/src/resources/subscriptions.d.ts.map +1 -0
- package/mcp/dist/src/resources/subscriptions.js +157 -0
- package/mcp/dist/src/resources/subscriptions.js.map +1 -0
- package/mcp/dist/src/server.d.ts +4 -0
- package/mcp/dist/src/server.d.ts.map +1 -0
- package/mcp/dist/src/server.js +59 -0
- package/mcp/dist/src/server.js.map +1 -0
- package/mcp/dist/src/tools/agents.d.ts +4 -0
- package/mcp/dist/src/tools/agents.d.ts.map +1 -0
- package/mcp/dist/src/tools/agents.js +317 -0
- package/mcp/dist/src/tools/agents.js.map +1 -0
- package/mcp/dist/src/tools/facts.d.ts +4 -0
- package/mcp/dist/src/tools/facts.d.ts.map +1 -0
- package/mcp/dist/src/tools/facts.js +151 -0
- package/mcp/dist/src/tools/facts.js.map +1 -0
- package/mcp/dist/src/tools/models.d.ts +4 -0
- package/mcp/dist/src/tools/models.d.ts.map +1 -0
- package/mcp/dist/src/tools/models.js +256 -0
- package/mcp/dist/src/tools/models.js.map +1 -0
- package/mcp/dist/src/tools/sessions.d.ts +4 -0
- package/mcp/dist/src/tools/sessions.d.ts.map +1 -0
- package/mcp/dist/src/tools/sessions.js +606 -0
- package/mcp/dist/src/tools/sessions.js.map +1 -0
- package/mcp/dist/src/util/command.d.ts +52 -0
- package/mcp/dist/src/util/command.d.ts.map +1 -0
- package/mcp/dist/src/util/command.js +78 -0
- package/mcp/dist/src/util/command.js.map +1 -0
- package/package.json +82 -6
- package/tui/README.md +35 -0
- package/tui/bin/tui.js +30 -0
- package/tui/plugins/.mcp.json +7 -0
- package/tui/plugins/plugin.json +13 -0
- package/tui/plugins/session-policy.json +8 -0
- package/tui/src/app.js +850 -0
- package/tui/src/auth/cli.js +111 -0
- package/tui/src/auth/entra-auth.js +218 -0
- package/tui/src/bootstrap-env.js +176 -0
- package/tui/src/embedded-workers.js +79 -0
- package/tui/src/http-transport-host.js +106 -0
- package/tui/src/index.js +340 -0
- package/tui/src/node-sdk-transport.js +1794 -0
- package/tui/src/platform.js +984 -0
- package/tui/src/plugin-config.js +273 -0
- package/tui/src/portal.js +7 -0
- package/tui/src/version.js +7 -0
- package/tui/tui-splash-mobile.txt +7 -0
- package/tui/tui-splash.txt +11 -0
- package/ui/core/README.md +6 -0
- package/ui/core/src/commands.js +93 -0
- package/ui/core/src/context-usage.js +212 -0
- package/ui/core/src/controller.js +6201 -0
- package/ui/core/src/formatting.js +1036 -0
- package/ui/core/src/history.js +950 -0
- package/ui/core/src/index.js +13 -0
- package/ui/core/src/layout.js +332 -0
- package/ui/core/src/reducer.js +1952 -0
- package/ui/core/src/selectors.js +5419 -0
- package/ui/core/src/session-errors.js +14 -0
- package/ui/core/src/session-tree.js +151 -0
- package/ui/core/src/state.js +251 -0
- package/ui/core/src/store.js +23 -0
- package/ui/core/src/system-titles.js +24 -0
- package/ui/core/src/themes/catppuccin-mocha.js +56 -0
- package/ui/core/src/themes/cobalt2.js +56 -0
- package/ui/core/src/themes/dark-high-contrast.js +56 -0
- package/ui/core/src/themes/daylight.js +62 -0
- package/ui/core/src/themes/dracula.js +56 -0
- package/ui/core/src/themes/github-dark.js +56 -0
- package/ui/core/src/themes/github-light.js +59 -0
- package/ui/core/src/themes/gruvbox-dark.js +56 -0
- package/ui/core/src/themes/hacker-x-matrix.js +56 -0
- package/ui/core/src/themes/hacker-x-orion-prime.js +56 -0
- package/ui/core/src/themes/helpers.js +79 -0
- package/ui/core/src/themes/high-contrast-mono.js +59 -0
- package/ui/core/src/themes/index.js +52 -0
- package/ui/core/src/themes/light-high-contrast.js +62 -0
- package/ui/core/src/themes/noctis-obscuro.js +56 -0
- package/ui/core/src/themes/noctis.js +56 -0
- package/ui/core/src/themes/paper-ink.js +62 -0
- package/ui/core/src/themes/solarized-ops.js +59 -0
- package/ui/core/src/themes/terminal-green.js +59 -0
- package/ui/core/src/themes/tokyo-night.js +56 -0
- package/ui/react/README.md +5 -0
- package/ui/react/src/chat-status.js +39 -0
- package/ui/react/src/components.js +1989 -0
- package/ui/react/src/index.js +4 -0
- package/ui/react/src/platform.js +15 -0
- package/ui/react/src/use-controller-state.js +38 -0
- package/ui/react/src/web-app.js +4457 -0
- package/web/README.md +198 -0
- package/web/api/router.js +196 -0
- package/web/api/ws.js +152 -0
- package/web/auth/authz/engine.js +204 -0
- package/web/auth/config.js +115 -0
- package/web/auth/index.js +175 -0
- package/web/auth/normalize/entra.js +22 -0
- package/web/auth/providers/entra.js +76 -0
- package/web/auth/providers/none.js +24 -0
- package/web/auth.js +10 -0
- package/web/bin/serve.js +53 -0
- package/web/config.js +20 -0
- package/web/dist/app.js +469 -0
- package/web/dist/assets/index-DmGOcKR-.css +1 -0
- package/web/dist/assets/index-xJ8IzIZY.js +24 -0
- package/web/dist/assets/msal-CytV9RFv.js +7 -0
- package/web/dist/assets/pilotswarm-D9pEmenA.js +90 -0
- package/web/dist/assets/react-CEPDSRB6.js +1 -0
- package/web/dist/index.html +16 -0
- package/web/runtime.js +455 -0
- package/web/server.js +276 -0
- package/index.js +0 -1
package/web/README.md
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# pilotswarm-web
|
|
2
|
+
|
|
3
|
+
Web portal for PilotSwarm — browser-based durable agent orchestration UI.
|
|
4
|
+
|
|
5
|
+
Full feature parity with the TUI: session management, real-time chat, agent
|
|
6
|
+
splash screens (ASCII art), sequence diagrams, node maps, worker logs,
|
|
7
|
+
binary-safe artifact downloads, metadata-aware browser previews, and keyboard shortcuts.
|
|
8
|
+
|
|
9
|
+
The portal server also hosts the versioned [PilotSwarm Web API](../../docs/api/reference.md)
|
|
10
|
+
(`/api/v1` HTTP + `/api/v1/ws` WebSocket) — see [Web API](#web-api) below.
|
|
11
|
+
|
|
12
|
+
## Quick Start
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# Install
|
|
16
|
+
npm install pilotswarm-web
|
|
17
|
+
|
|
18
|
+
# Run (starts server + serves React app)
|
|
19
|
+
npx pilotswarm-web --env .env.remote
|
|
20
|
+
npx pilotswarm-web --env .env.remote --plugin ./plugin
|
|
21
|
+
|
|
22
|
+
# Development (Vite HMR)
|
|
23
|
+
cd packages/app/web
|
|
24
|
+
npm run dev # React app at http://localhost:5173
|
|
25
|
+
node server.js # API server at http://localhost:3001
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Portal Customization
|
|
29
|
+
|
|
30
|
+
The web portal reads app-facing customization from `plugin.json` in your app
|
|
31
|
+
plugin directory. Pass the plugin path with `--plugin` or set `PLUGIN_DIRS`
|
|
32
|
+
so the portal process can see the same metadata the TUI and worker use.
|
|
33
|
+
|
|
34
|
+
Supported keys:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"tui": {
|
|
39
|
+
"title": "DevOps Command Center",
|
|
40
|
+
"splashFile": "./tui-splash.txt"
|
|
41
|
+
},
|
|
42
|
+
"portal": {
|
|
43
|
+
"branding": {
|
|
44
|
+
"title": "DevOps Command Center",
|
|
45
|
+
"pageTitle": "DevOps Command Center Portal",
|
|
46
|
+
"splashFile": "./tui-splash.txt",
|
|
47
|
+
"logoFile": "./assets/logo.svg",
|
|
48
|
+
"faviconFile": "./assets/favicon.png"
|
|
49
|
+
},
|
|
50
|
+
"ui": {
|
|
51
|
+
"loadingMessage": "Preparing the DevOps workspace",
|
|
52
|
+
"loadingCopy": "Connecting dashboards, session feeds, and orchestration state..."
|
|
53
|
+
},
|
|
54
|
+
"auth": {
|
|
55
|
+
"provider": "entra",
|
|
56
|
+
"signInTitle": "Sign in to DevOps Command Center",
|
|
57
|
+
"signInMessage": "Use your organization's identity provider to open the shared operations workspace.",
|
|
58
|
+
"signInLabel": "Sign In"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Notes:
|
|
65
|
+
|
|
66
|
+
- Preferred schema is nested: `portal.branding`, `portal.ui`, and `portal.auth`.
|
|
67
|
+
- Flat legacy keys such as `portal.title` and `portal.loadingMessage` are still accepted for backwards compatibility.
|
|
68
|
+
- `portal.auth.provider` selects the active auth provider when the deployment does not override it with `PORTAL_AUTH_PROVIDER`.
|
|
69
|
+
- `branding.logoFile` is used on the loading splash, sign-in card, and signed-in header.
|
|
70
|
+
- If `branding.faviconFile` is omitted, the browser tab icon reuses `branding.logoFile`.
|
|
71
|
+
- Keep logo assets inside the plugin directory so the portal image can package and serve them alongside `plugin.json`.
|
|
72
|
+
|
|
73
|
+
Fallback order:
|
|
74
|
+
|
|
75
|
+
- `portal.branding.*` / `portal.ui.*` / `portal.auth.*`
|
|
76
|
+
- flat `portal.*`
|
|
77
|
+
- `tui.title` / `tui.splash` / `tui.splashFile`
|
|
78
|
+
- built-in `PilotSwarm` defaults
|
|
79
|
+
|
|
80
|
+
Named-agent creation in the portal comes from the same plugin metadata surface.
|
|
81
|
+
If the portal process cannot see your plugin directory, the web UI falls back
|
|
82
|
+
to generic sessions even when the worker supports named agents.
|
|
83
|
+
|
|
84
|
+
Artifact behavior:
|
|
85
|
+
|
|
86
|
+
- text artifacts still preview inline in the browser workspace
|
|
87
|
+
- binary artifacts render as a download-only card in the browser workspace
|
|
88
|
+
- downloads preserve the stored content type and raw bytes
|
|
89
|
+
|
|
90
|
+
## Auth Add-Ons
|
|
91
|
+
|
|
92
|
+
Portal authentication is provider-based.
|
|
93
|
+
|
|
94
|
+
- Default: `none`
|
|
95
|
+
- Built-in optional provider: `entra`
|
|
96
|
+
- AuthZ is common across providers and currently supports Phase 1 group-based admission control
|
|
97
|
+
|
|
98
|
+
Enable Entra ID with env vars:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
PORTAL_AUTH_PROVIDER=entra
|
|
102
|
+
PORTAL_AUTH_ENTRA_TENANT_ID=<tenant-id>
|
|
103
|
+
PORTAL_AUTH_ENTRA_CLIENT_ID=<client-id>
|
|
104
|
+
PORTAL_AUTHZ_ADMIN_GROUPS=admin1@contoso.com,admin2@contoso.com
|
|
105
|
+
PORTAL_AUTHZ_USER_GROUPS=user1@contoso.com,user2@contoso.com
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Notes:
|
|
109
|
+
|
|
110
|
+
- `PORTAL_AUTHZ_ADMIN_GROUPS` and `PORTAL_AUTHZ_USER_GROUPS` are currently comma-delimited email allowlists despite the historical variable names.
|
|
111
|
+
- `PORTAL_AUTHZ_DEFAULT_ROLE` defaults to `none` (deny-by-default since v0.1.33). If no admin/user groups are configured and the JWT carries no role claim, a signed-in user is **denied** at the portal layer. Set `PORTAL_AUTHZ_DEFAULT_ROLE=user` to restore the legacy "any tenant user gets `user`" open posture (sandbox stamps only).
|
|
112
|
+
- `admin` and `user` have the same portal permissions today; the allowlists act as an admission gate and role assignment surface.
|
|
113
|
+
|
|
114
|
+
### App Roles (Recommended For IT-Managed Tenants)
|
|
115
|
+
|
|
116
|
+
When the Entra app registration defines `admin` and `user` app roles
|
|
117
|
+
and assigns them, the portal decides admission from the JWT `roles`
|
|
118
|
+
claim — the role assignment in Entra **is** the allowlist for this
|
|
119
|
+
posture. The portal matches the claim by case-insensitive equality
|
|
120
|
+
against the canonical values `admin` and `user`; there is no override
|
|
121
|
+
env var. With deny-by-default (the default since v0.1.33), unassigned
|
|
122
|
+
signed-in users are denied at the portal layer.
|
|
123
|
+
|
|
124
|
+
The email-allowlist path (`PORTAL_AUTHZ_ADMIN_GROUPS` /
|
|
125
|
+
`PORTAL_AUTHZ_USER_GROUPS`) is bypassed entirely when the JWT carries
|
|
126
|
+
a `roles[]` claim — use it only for stamps that do not use
|
|
127
|
+
`-CreateAppRoles`. Flipping `appRoleAssignmentRequired=true` on the
|
|
128
|
+
Enterprise Application adds an Entra-side gate as well; it is optional
|
|
129
|
+
and carries a restricted-tenant caveat (AADSTS90094 admin-consent
|
|
130
|
+
prompts). See
|
|
131
|
+
[`../../docs/portal-entra-app-roles.md`](../../docs/developer/deploy/entra-app-roles.md)
|
|
132
|
+
for the full operator runbook.
|
|
133
|
+
|
|
134
|
+
The portal core no longer assumes Entra specifically. New providers can plug
|
|
135
|
+
into the same browser/server provider interfaces, while sharing the same common
|
|
136
|
+
authz layer.
|
|
137
|
+
|
|
138
|
+
## Web API
|
|
139
|
+
|
|
140
|
+
The portal server also hosts the versioned PilotSwarm Web API — same process,
|
|
141
|
+
same port (3001):
|
|
142
|
+
|
|
143
|
+
- **HTTP `/api/v1`** — routes generated from the `pilotswarm-sdk/api`
|
|
144
|
+
operations table (`packages/sdk/api/src/protocol.js`, the contract source
|
|
145
|
+
of truth) and dispatched through the existing runtime dispatcher — the same
|
|
146
|
+
one the legacy `/api/rpc` uses, so the two surfaces cannot drift.
|
|
147
|
+
- **WebSocket `/api/v1/ws`** — session event and log streaming.
|
|
148
|
+
- **Auth** — reuses the portal's provider-based auth; the same `PORTAL_AUTH_*`
|
|
149
|
+
and `PORTAL_AUTHZ_*` env vars apply, no separate configuration.
|
|
150
|
+
- Errors use the `{ ok: false, error: { code, message } }` envelope.
|
|
151
|
+
|
|
152
|
+
See [`docs/api/reference.md`](../../docs/api/reference.md) for the full API
|
|
153
|
+
reference.
|
|
154
|
+
|
|
155
|
+
The legacy `/api/rpc` and `/portal-ws` endpoints remain mounted for a
|
|
156
|
+
deprecation window; new integrations must use `/api/v1`.
|
|
157
|
+
|
|
158
|
+
## Architecture
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
Browser (React + Vite)
|
|
162
|
+
│
|
|
163
|
+
└── BrowserPortalTransport (extends HttpApiTransport)
|
|
164
|
+
│
|
|
165
|
+
├── HTTP ──────► /api/v1 ─┐
|
|
166
|
+
│ ├─ Portal Server (Express + ws)
|
|
167
|
+
└── WebSocket ─► /api/v1/ws ─┘
|
|
168
|
+
│
|
|
169
|
+
├── PilotSwarmClient
|
|
170
|
+
├── PilotSwarmManagementClient
|
|
171
|
+
└── PilotSwarmWorker (embedded or remote)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The browser UI talks `/api/v1` itself via `BrowserPortalTransport`, which
|
|
175
|
+
extends `HttpApiTransport` from `pilotswarm-sdk/api` — the same client
|
|
176
|
+
surface external integrations use.
|
|
177
|
+
|
|
178
|
+
Same public API boundary as the TUI — only `PilotSwarmClient`,
|
|
179
|
+
`PilotSwarmManagementClient`, and `PilotSwarmWorker` APIs. No internal
|
|
180
|
+
module imports.
|
|
181
|
+
|
|
182
|
+
## Package Relationship
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
pilotswarm (the app package; this dir is its web/ tree)
|
|
186
|
+
├── pilotswarm/host (shared node/runtime host glue, tui/src/portal.js)
|
|
187
|
+
│ └── pilotswarm-sdk
|
|
188
|
+
├── pilotswarm/ui-core, pilotswarm/ui-react (internal UI layers)
|
|
189
|
+
├── express
|
|
190
|
+
├── ws
|
|
191
|
+
├── react, react-dom
|
|
192
|
+
└── vite (devDependency)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`pilotswarm-web` now consumes a small supported portal-facing surface from
|
|
196
|
+
`pilotswarm-cli` rather than importing monorepo-relative source files. That
|
|
197
|
+
keeps the publishable package graph explicit and lets the portal reuse the same
|
|
198
|
+
Node transport and plugin-config behavior as the TUI.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { API_VERSION, OPERATIONS, coerceQueryValue } from "pilotswarm-sdk/api";
|
|
4
|
+
import { getAuthConfig } from "../auth.js";
|
|
5
|
+
import { getPublicAuthContext } from "../auth/authz/engine.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The versioned Web API router (`/api/v1`).
|
|
9
|
+
*
|
|
10
|
+
* Every JSON operation is generated from the pilotswarm-sdk/api
|
|
11
|
+
* operations table and delegates to `runtime.call(name, params, req.auth)` —
|
|
12
|
+
* the same dispatcher the legacy `/api/rpc` uses, so the two surfaces cannot
|
|
13
|
+
* drift. Only health/auth/bootstrap and the binary artifact download are
|
|
14
|
+
* hand-written routes.
|
|
15
|
+
*
|
|
16
|
+
* Error envelope: `{ ok: false, error: { code, message } }`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const ERROR_STATUS_BY_CODE = {
|
|
20
|
+
INVALID_REQUEST: 400,
|
|
21
|
+
PORTAL_AUTH_REQUIRED: 401,
|
|
22
|
+
UNAUTHORIZED: 401,
|
|
23
|
+
FORBIDDEN: 403,
|
|
24
|
+
NOT_FOUND: 404,
|
|
25
|
+
// Capability mismatches: the deployment's store doesn't support the op.
|
|
26
|
+
FACTS_ENHANCED_UNSUPPORTED: 409,
|
|
27
|
+
GRAPH_UNSUPPORTED: 409,
|
|
28
|
+
PAYLOAD_TOO_LARGE: 413,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Domain/lifecycle errors the runtime throws with actionable, non-sensitive
|
|
32
|
+
// messages. These are client errors (their message is the whole value), so
|
|
33
|
+
// they keep their text; everything else that reaches 500 is treated as an
|
|
34
|
+
// unexpected fault and gets a generic message.
|
|
35
|
+
const CLIENT_ERROR_MESSAGE = /must be|is required|cannot be|Unsupported|is not started|terminal orchestration|cannot accept new messages|not found|already|Unknown model|does not support reasoning effort/i;
|
|
36
|
+
|
|
37
|
+
function statusForError(error) {
|
|
38
|
+
const byCode = ERROR_STATUS_BY_CODE[error?.code];
|
|
39
|
+
if (byCode) return byCode;
|
|
40
|
+
if (Number.isInteger(error?.status) && error.status >= 400 && error.status <= 599) return error.status;
|
|
41
|
+
const message = String(error?.message || "");
|
|
42
|
+
if (/is not started|terminal orchestration|cannot accept new messages|already/i.test(message)) return 409;
|
|
43
|
+
if (CLIENT_ERROR_MESSAGE.test(message)) return 400;
|
|
44
|
+
return 500;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sendError(res, error, fallbackStatus) {
|
|
48
|
+
const status = fallbackStatus || statusForError(error);
|
|
49
|
+
const code = error?.code || (status === 404 ? "NOT_FOUND" : status === 400 ? "INVALID_REQUEST" : status === 413 ? "PAYLOAD_TOO_LARGE" : "INTERNAL_ERROR");
|
|
50
|
+
// 500s are unexpected server faults; their raw messages can leak
|
|
51
|
+
// connection strings, file paths, or stack detail. Send a generic
|
|
52
|
+
// message and keep the code for correlation. Client-facing errors
|
|
53
|
+
// (4xx: validation, not-found, auth, lifecycle conflicts) keep their
|
|
54
|
+
// message because it is actionable and non-sensitive.
|
|
55
|
+
const message = status >= 500 ? "Internal server error" : (error?.message || String(error));
|
|
56
|
+
res.status(status).json({ ok: false, error: { code, message } });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Session/child id path params must look like ids — never a path fragment.
|
|
60
|
+
// This is the trust boundary for the filesystem artifact store, which builds
|
|
61
|
+
// paths from these values; anything with a separator or ".." is rejected here
|
|
62
|
+
// before it can reach a sink. (Group ids are validated the same way.)
|
|
63
|
+
const ID_PARAM_KEYS = new Set(["sessionId", "parentSessionId", "childSessionId", "agentIdOrSessionId", "groupId"]);
|
|
64
|
+
const SAFE_ID = /^[\w:.-]{1,200}$/;
|
|
65
|
+
|
|
66
|
+
function assertSafeIdParams(op, params) {
|
|
67
|
+
for (const key of Object.keys(op.params || {})) {
|
|
68
|
+
if (!ID_PARAM_KEYS.has(key)) continue;
|
|
69
|
+
const value = params[key];
|
|
70
|
+
if (value == null) continue;
|
|
71
|
+
if (!SAFE_ID.test(String(value)) || String(value).includes("..")) {
|
|
72
|
+
throw Object.assign(new Error(`Invalid ${key}`), { code: "INVALID_REQUEST" });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A caller is privileged (admin) when the validated token carries the admin
|
|
78
|
+
// role, OR the deployment is no-auth (role "anonymous" — no-auth means a
|
|
79
|
+
// trusted/private deployment with full access, consistent with the rest of
|
|
80
|
+
// the API's binary admission model).
|
|
81
|
+
function isAdminAuth(auth) {
|
|
82
|
+
const role = auth?.authorization?.role;
|
|
83
|
+
return role === "admin" || role === "anonymous";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function collectParams(op, req) {
|
|
87
|
+
const params = {};
|
|
88
|
+
for (const [key, spec] of Object.entries(op.params || {})) {
|
|
89
|
+
if (spec.in === "path") {
|
|
90
|
+
params[key] = req.params[spec.name || key];
|
|
91
|
+
} else if (spec.in === "query") {
|
|
92
|
+
const value = coerceQueryValue(req.query[key], spec.type);
|
|
93
|
+
if (value !== undefined) params[key] = value;
|
|
94
|
+
} else if (req.body && Object.prototype.hasOwnProperty.call(req.body, key)) {
|
|
95
|
+
params[key] = req.body[key];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return params;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function createApiRouter({ runtime, requireAuth }) {
|
|
102
|
+
const router = express.Router();
|
|
103
|
+
|
|
104
|
+
// ── Public routes ───────────────────────────────────────────────────
|
|
105
|
+
router.get("/health", (_req, res) => {
|
|
106
|
+
res.json({ ok: true, started: runtime.started, mode: runtime.mode, apiVersion: API_VERSION });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
router.get("/auth/config", async (req, res) => {
|
|
110
|
+
try {
|
|
111
|
+
res.json(await getAuthConfig(req));
|
|
112
|
+
} catch (error) {
|
|
113
|
+
sendError(res, error);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ── Authenticated routes ────────────────────────────────────────────
|
|
118
|
+
router.use(requireAuth);
|
|
119
|
+
|
|
120
|
+
router.get("/auth/me", (req, res) => {
|
|
121
|
+
res.json({ ok: true, ...getPublicAuthContext(req.auth) });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
router.get("/bootstrap", async (req, res) => {
|
|
125
|
+
try {
|
|
126
|
+
const bootstrap = await runtime.getBootstrap();
|
|
127
|
+
res.json({ ok: true, ...bootstrap, apiVersion: API_VERSION, auth: getPublicAuthContext(req.auth) });
|
|
128
|
+
} catch (error) {
|
|
129
|
+
sendError(res, error);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Binary artifact download (streams; not part of the JSON envelope).
|
|
134
|
+
router.get("/sessions/:sessionId/artifacts/:filename/download", async (req, res) => {
|
|
135
|
+
if (!SAFE_ID.test(String(req.params.sessionId)) || String(req.params.sessionId).includes("..")) {
|
|
136
|
+
sendError(res, Object.assign(new Error("Invalid sessionId"), { code: "INVALID_REQUEST" }), 400);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const artifact = await runtime.downloadArtifactBinary(req.params.sessionId, req.params.filename);
|
|
141
|
+
res.setHeader("content-type", String(artifact?.contentType || "application/octet-stream"));
|
|
142
|
+
res.setHeader("content-disposition", `attachment; filename="${path.basename(req.params.filename)}"`);
|
|
143
|
+
res.send(artifact.body);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
sendError(res, error, 404);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ── Generated operation routes ──────────────────────────────────────
|
|
150
|
+
for (const op of OPERATIONS) {
|
|
151
|
+
const expressPath = op.path.replace(/:([\w]+)/g, ":$1");
|
|
152
|
+
router[op.method.toLowerCase()](expressPath, async (req, res) => {
|
|
153
|
+
try {
|
|
154
|
+
// Tier-2 operational ops require the admin role. This is the
|
|
155
|
+
// API's only per-route role check; every other op shares the
|
|
156
|
+
// binary admission gate (requireAuth above).
|
|
157
|
+
if (op.admin && !isAdminAuth(req.auth)) {
|
|
158
|
+
sendError(res, Object.assign(new Error("This operation requires the admin role."), { code: "FORBIDDEN" }), 403);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const params = collectParams(op, req);
|
|
162
|
+
assertSafeIdParams(op, params);
|
|
163
|
+
const result = await runtime.call(op.name, params, req.auth);
|
|
164
|
+
res.json({ ok: true, result: result === undefined ? null : result });
|
|
165
|
+
} catch (error) {
|
|
166
|
+
sendError(res, error);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Envelope 404 for unknown API paths ──────────────────────────────
|
|
172
|
+
router.use((req, res) => {
|
|
173
|
+
res.status(404).json({
|
|
174
|
+
ok: false,
|
|
175
|
+
error: { code: "NOT_FOUND", message: `Unknown API route: ${req.method} ${req.baseUrl}${req.path}` },
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ── Error middleware: body-parser failures (oversized/malformed JSON)
|
|
180
|
+
// reach here as thrown errors rather than route rejections; map them to
|
|
181
|
+
// the same envelope instead of Express's default HTML error page.
|
|
182
|
+
router.use((error, _req, res, _next) => {
|
|
183
|
+
if (res.headersSent) return;
|
|
184
|
+
if (error?.type === "entity.too.large") {
|
|
185
|
+
sendError(res, Object.assign(new Error("Request body too large"), { code: "PAYLOAD_TOO_LARGE" }), 413);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (error?.type === "entity.parse.failed" || error instanceof SyntaxError) {
|
|
189
|
+
sendError(res, Object.assign(new Error("Malformed JSON body"), { code: "INVALID_REQUEST" }), 400);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
sendError(res, error);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return router;
|
|
196
|
+
}
|
package/web/api/ws.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { WebSocketServer } from "ws";
|
|
2
|
+
import { authenticateToken, extractToken } from "../auth.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The Web API streaming endpoint: session events and the live log tail.
|
|
6
|
+
*
|
|
7
|
+
* One connection handler serves both `/api/v1/ws` (the product API) and the
|
|
8
|
+
* legacy `/portal-ws` (which additionally answers the portal-only `theme`
|
|
9
|
+
* message). Vocabulary:
|
|
10
|
+
* client -> server: subscribeSession | unsubscribeSession | subscribeLogs | unsubscribeLogs
|
|
11
|
+
* server -> client: ready | subscribedSession | sessionEvent | subscribedLogs | logEntry | error
|
|
12
|
+
*
|
|
13
|
+
* Delivery here is an acceleration path — correctness comes from event
|
|
14
|
+
* replay via GET /api/v1/management/sessions/:id/events?afterSeq=… after a
|
|
15
|
+
* reconnect.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function isSafeThemeId(value) {
|
|
19
|
+
return /^[\w-]+$/u.test(String(value || ""));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createConnectionHandler(runtime, { allowThemeMessages = false } = {}) {
|
|
23
|
+
return async function handleConnection(ws, req) {
|
|
24
|
+
const auth = await authenticateToken(extractToken(req), req);
|
|
25
|
+
if (!auth.ok) {
|
|
26
|
+
ws.close(auth.status === 403 ? 4403 : 4401, auth.error || (auth.status === 403 ? "Forbidden" : "Unauthorized"));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const sessionSubscriptions = new Map();
|
|
31
|
+
let logUnsubscribe = null;
|
|
32
|
+
|
|
33
|
+
const send = (message) => {
|
|
34
|
+
if (ws.readyState === ws.OPEN) {
|
|
35
|
+
ws.send(JSON.stringify(message));
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
send({ type: "ready" });
|
|
40
|
+
|
|
41
|
+
ws.on("message", async (raw) => {
|
|
42
|
+
let message;
|
|
43
|
+
try {
|
|
44
|
+
message = JSON.parse(String(raw));
|
|
45
|
+
} catch {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const type = String(message?.type || "");
|
|
50
|
+
if (type === "subscribeSession") {
|
|
51
|
+
const sessionId = String(message?.sessionId || "").trim();
|
|
52
|
+
if (!sessionId || sessionSubscriptions.has(sessionId)) return;
|
|
53
|
+
try {
|
|
54
|
+
await runtime.start();
|
|
55
|
+
const unsubscribe = runtime.subscribeSession(sessionId, (event) => {
|
|
56
|
+
send({ type: "sessionEvent", sessionId, event });
|
|
57
|
+
});
|
|
58
|
+
sessionSubscriptions.set(sessionId, unsubscribe);
|
|
59
|
+
send({ type: "subscribedSession", sessionId });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
send({ type: "error", scope: "session", sessionId, error: error?.message || String(error) });
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (type === "unsubscribeSession") {
|
|
67
|
+
const sessionId = String(message?.sessionId || "").trim();
|
|
68
|
+
const unsubscribe = sessionSubscriptions.get(sessionId);
|
|
69
|
+
if (unsubscribe) {
|
|
70
|
+
unsubscribe();
|
|
71
|
+
sessionSubscriptions.delete(sessionId);
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (type === "subscribeLogs") {
|
|
77
|
+
if (logUnsubscribe) return;
|
|
78
|
+
try {
|
|
79
|
+
await runtime.start();
|
|
80
|
+
logUnsubscribe = runtime.startLogTail((entry) => {
|
|
81
|
+
send({ type: "logEntry", entry });
|
|
82
|
+
});
|
|
83
|
+
send({ type: "subscribedLogs" });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
send({ type: "error", scope: "logs", error: error?.message || String(error) });
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (type === "unsubscribeLogs") {
|
|
91
|
+
if (logUnsubscribe) {
|
|
92
|
+
logUnsubscribe();
|
|
93
|
+
logUnsubscribe = null;
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (allowThemeMessages && type === "theme" && isSafeThemeId(message?.themeId)) {
|
|
99
|
+
send({ type: "themeAck", themeId: message.themeId });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
ws.on("close", () => {
|
|
104
|
+
for (const unsubscribe of sessionSubscriptions.values()) {
|
|
105
|
+
try {
|
|
106
|
+
unsubscribe();
|
|
107
|
+
} catch {}
|
|
108
|
+
}
|
|
109
|
+
sessionSubscriptions.clear();
|
|
110
|
+
if (logUnsubscribe) {
|
|
111
|
+
try {
|
|
112
|
+
logUnsubscribe();
|
|
113
|
+
} catch {}
|
|
114
|
+
logUnsubscribe = null;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Mount WebSocket endpoints on the shared HTTP server.
|
|
122
|
+
*
|
|
123
|
+
* Multiple path-bound WebSocketServers cannot share one HTTP server (each
|
|
124
|
+
* competes for the 'upgrade' event), so this routes upgrades manually with
|
|
125
|
+
* noServer-mode servers — one per endpoint.
|
|
126
|
+
*
|
|
127
|
+
* @param {import("node:http").Server} server
|
|
128
|
+
* @param {object} runtime
|
|
129
|
+
* @param {Array<{ path: string, allowThemeMessages?: boolean }>} endpoints
|
|
130
|
+
* @returns {WebSocketServer[]}
|
|
131
|
+
*/
|
|
132
|
+
export function attachWebSockets(server, runtime, endpoints) {
|
|
133
|
+
const byPath = new Map(endpoints.map(({ path, allowThemeMessages = false }) => {
|
|
134
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
135
|
+
wss.on("connection", createConnectionHandler(runtime, { allowThemeMessages }));
|
|
136
|
+
return [path, wss];
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
server.on("upgrade", (req, socket, head) => {
|
|
140
|
+
const { pathname } = new URL(req.url || "/", "http://localhost");
|
|
141
|
+
const wss = byPath.get(pathname);
|
|
142
|
+
if (!wss) {
|
|
143
|
+
socket.destroy();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
147
|
+
wss.emit("connection", ws, req);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return [...byPath.values()];
|
|
152
|
+
}
|