Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
427 lines
14 KiB
Markdown
427 lines
14 KiB
Markdown
# Extensions
|
|
|
|
Plugin system with three tiers: Browser JS (client-side), Starlark
|
|
sandbox (server-side, v0.29.0), Sidecar containers (server-side, future).
|
|
|
|
### User Extensions
|
|
|
|
**Auth:** Authenticated user
|
|
|
|
```
|
|
GET /extensions → {"data": [...UserExtension]}
|
|
?tier=browser (optional filter by tier)
|
|
POST /extensions/:id/settings ← {"is_enabled": bool, "settings": {...}}
|
|
:id = extension UUID → {"ok": true}
|
|
GET /extensions/:id/manifest → {"data": <manifest JSON>}
|
|
:id = ext_id (manifest id)
|
|
GET /extensions/tools → {"data": [...tool schema objects]}
|
|
```
|
|
|
|
**Notes:**
|
|
- `GET /extensions` returns `UserExtension` objects: the base extension
|
|
fields plus `user_enabled` and `user_settings` overrides.
|
|
- System extensions (`is_system: true`) cannot be disabled by users.
|
|
Attempting to set `is_enabled: false` on a system extension returns 403.
|
|
- `GET /extensions/tools` returns raw tool schema JSON from all enabled
|
|
browser extensions' `manifest.tools[]` arrays.
|
|
|
|
### Extension API Routes (v0.29.1)
|
|
|
|
Starlark packages serve custom JSON endpoints. Mounted at
|
|
`/s/:slug/api/*path` with JWT authentication.
|
|
|
|
**Auth:** Authenticated user (JWT — returns 401, not redirect)
|
|
|
|
```
|
|
ANY /s/:slug/api/*path → Starlark on_request(req) response
|
|
```
|
|
|
|
**Route:** `slug` is the package ID. `path` is matched against the
|
|
manifest's `api_routes` declaration.
|
|
|
|
**Manifest `api_routes` field:**
|
|
|
|
```json
|
|
{
|
|
"api_routes": [
|
|
{"method": "GET", "path": "/status"},
|
|
{"method": "POST", "path": "/webhook"},
|
|
{"method": "*", "path": "/proxy/*"}
|
|
]
|
|
}
|
|
```
|
|
|
|
- `method`: exact match (case-insensitive), or `"*"` for any method
|
|
- `path`: exact match, or trailing `"*"` for prefix match
|
|
- Boolean `true` shorthand: all routes forwarded
|
|
- Missing or `false`: no routes served
|
|
|
|
**Request dict passed to `on_request(req)`:**
|
|
|
|
```python
|
|
{
|
|
"method": "POST",
|
|
"path": "/webhook",
|
|
"headers": {"content-type": "application/json", ...},
|
|
"query": {"page": "1", ...},
|
|
"body": "{...}",
|
|
"user_id": "uuid"
|
|
}
|
|
```
|
|
|
|
**Expected return dict:**
|
|
|
|
```python
|
|
{"status": 200, "headers": {"X-Custom": "val"}, "body": "{...}"}
|
|
```
|
|
|
|
- `status`: int (default 200). Return `None` for 204 No Content.
|
|
- `headers`: dict (optional). Content-Type auto-detected if not set.
|
|
- `body`: string (default "").
|
|
|
|
**Validation pipeline:**
|
|
1. Package exists and is enabled
|
|
2. Status is `active` (not `pending_review` or `suspended`)
|
|
3. Tier is `starlark`
|
|
4. `api.http` permission is granted
|
|
5. Method+path matches `api_routes` in manifest
|
|
|
|
**Errors:**
|
|
- `401` — no/invalid JWT
|
|
- `403` — package suspended or missing `api.http` permission
|
|
- `404` — package not found, disabled, or route not declared
|
|
- `400` — package is not starlark tier
|
|
- `500` — Starlark execution error
|
|
|
|
### Admin Extension Management
|
|
|
|
**Auth:** Admin role
|
|
|
|
```
|
|
GET /admin/extensions → {"data": [...Extension]}
|
|
POST /admin/extensions ← {ext_id*, name*, version?, tier?,
|
|
description?, author?, manifest?,
|
|
is_system?, is_enabled?}
|
|
→ {"data": Extension} (201)
|
|
PUT /admin/extensions/:id ← {name?, version?, description?,
|
|
:id = extension UUID author?, is_system?, is_enabled?,
|
|
manifest?}
|
|
→ {"data": Extension}
|
|
DELETE /admin/extensions/:id → {"ok": true}
|
|
:id = extension UUID
|
|
```
|
|
|
|
**Install defaults:** `version` → `"0.0.0"`, `tier` → `"browser"`,
|
|
`manifest` → `{}`, `scope` → `"global"`.
|
|
|
|
**Tier validation:** `tier` must be one of: `browser`, `starlark`, `sidecar`.
|
|
|
|
**Duplicate rejection:** If `ext_id` is already installed, returns 409.
|
|
|
|
### Asset Serving
|
|
|
|
**Auth:** None (public — script tags can't send Authorization headers)
|
|
|
|
```
|
|
GET /extensions/:id/assets/*path → application/javascript
|
|
:id = ext_id (manifest id)
|
|
```
|
|
|
|
Returns the inline `_script` field from the extension's manifest.
|
|
The `*path` segment is accepted but currently ignored (all requests
|
|
return the same script). Disabled extensions return 404.
|
|
|
|
### Extension Permissions (v0.29.0+)
|
|
|
|
Starlark packages declare capabilities in `manifest.permissions`.
|
|
Admin must grant each before the package activates.
|
|
|
|
| Permission | Module | Description |
|
|
|-----------|--------|-------------|
|
|
| `secrets.read` | `secrets` | Read extension secrets via GlobalConfig |
|
|
| `notifications.send` | `notifications` | Send in-app notifications |
|
|
| `filters.pre_completion` | — | Register pre-completion filter |
|
|
| `api.http` | `http` | Outbound HTTP requests (v0.29.0: module, v0.29.1: also required for API routes) |
|
|
| `provider.complete` | `provider` | LLM completion calls via BYOK chain (v0.29.1) |
|
|
| `db.read` | `db` | Read extension tables and platform views (v0.29.2) |
|
|
| `db.write` | `db` | Write extension tables (v0.29.2) |
|
|
|
|
### Starlark Modules (v0.29.0+)
|
|
|
|
Modules injected into the script namespace based on granted permissions:
|
|
|
|
**`secrets`** (requires `secrets.read`):
|
|
- `secrets.get(key)` → string or None
|
|
- `secrets.list()` → list of key names
|
|
|
|
**`notifications`** (requires `notifications.send`):
|
|
- `notifications.send(user_id, title, body?, type?)` → True
|
|
|
|
**`http`** (requires `api.http`, v0.29.1):
|
|
- `http.get(url, headers?)` → `{"status": int, "headers": dict, "body": string}`
|
|
- `http.post(url, body?, headers?)` → response dict
|
|
- `http.put(url, body?, headers?)` → response dict
|
|
- `http.delete(url, headers?)` → response dict
|
|
- `http.request(method, url, body?, headers?)` → response dict
|
|
- SSRF protection: private/loopback/link-local IPs blocked after DNS
|
|
- Manifest `network_access`: `{"allow": [...]}` or `{"block": [...]}`
|
|
|
|
**`provider`** (requires `provider.complete`, v0.29.1):
|
|
- `provider.complete(messages, model?, max_tokens?, temperature?)` → response dict
|
|
- Response: `{"content", "model", "finish_reason", "input_tokens", "output_tokens"}`
|
|
- Provider resolved via BYOK chain; pinnable via `requires_provider.provider_config_id`
|
|
|
|
**`db`** (requires `db.read` or `db.write`, v0.29.2):
|
|
- `db.query(table, filters=None, order=None, limit=100)` → list of dicts
|
|
- `table`: logical name (physical: `ext_{pkg_slug}_{table}`)
|
|
- `filters`: dict of `{column: value}` equality filters (optional)
|
|
- `order`: `"col"` or `"-col"` for descending (optional)
|
|
- `limit`: max rows (default 100)
|
|
- `db.insert(table, row_dict)` → inserted row dict with auto-generated `id` (`db.write`)
|
|
- `db.update(table, id, partial_dict)` → True (`db.write`)
|
|
- `db.delete(table, id)` → True (`db.write`)
|
|
- `db.list_tables()` → list of logical table names for this package
|
|
- `db.view(view_name, filters=None, limit=100)` → list of dicts
|
|
- Allowed view names: `"users"` → `ext_view_users`, `"channels"` → `ext_view_channels`
|
|
- `ext_view_users`: `id`, `display_name`, `email`
|
|
- `ext_view_channels`: `id`, `title`, `type`, `team_id`
|
|
|
|
### Extension Database Tables (v0.29.2)
|
|
|
|
Starlark packages declare owned tables in `manifest.db_tables`. Tables
|
|
are created on install and dropped on uninstall.
|
|
|
|
**Manifest `db_tables` field:**
|
|
|
|
```json
|
|
{
|
|
"db_tables": {
|
|
"logs": {
|
|
"columns": {
|
|
"message": "text",
|
|
"user_id": "text",
|
|
"count": "int",
|
|
"score": "real",
|
|
"active": "bool",
|
|
"created_at": "timestamp"
|
|
},
|
|
"indexes": [
|
|
["user_id"],
|
|
["user_id", "created_at"]
|
|
]
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- **Physical name**: `ext_{pkg_slug}_{logical_name}` (hyphens → underscores)
|
|
- **Auto-columns**: `id TEXT PRIMARY KEY` (UUID generated on insert),
|
|
`created_at` (dialect-correct timestamp default)
|
|
- **Column types**: `text`, `int`/`integer`, `real`/`float`,
|
|
`bool`/`boolean`, `timestamp` — mapped to dialect-correct SQL
|
|
- **Indexes**: each entry is a list of columns for a composite index
|
|
- **Dialect**: PG uses `TIMESTAMPTZ`/`BOOLEAN`; SQLite uses `TEXT`/`INTEGER`
|
|
- **Catalog**: tracked in `ext_data_tables` per package for lifecycle management
|
|
|
|
### Extension Tools (v0.29.2)
|
|
|
|
Starlark packages declare server-side tools in `manifest.tools`. These
|
|
are included in `BuildToolDefs` alongside server tools. The completion
|
|
tool loop dispatches matched calls to the `on_tool_call` entry point.
|
|
|
|
**Manifest `tools` field (starlark tier only):**
|
|
|
|
```json
|
|
{
|
|
"tier": "starlark",
|
|
"permissions": ["db.read"],
|
|
"tools": [
|
|
{
|
|
"name": "search_logs",
|
|
"description": "Search extension log entries",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string"},
|
|
"user_id": {"type": "string"}
|
|
},
|
|
"required": ["query"]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
**`on_tool_call(call)` entry point:**
|
|
|
|
Called by the completion tool loop when a tool declared in `tools` is
|
|
invoked by the LLM.
|
|
|
|
```python
|
|
def on_tool_call(call):
|
|
# call dict:
|
|
# {
|
|
# "tool_name": "search_logs",
|
|
# "tool_call_id": "call_abc123",
|
|
# "arguments": {"query": "hello", "user_id": "u1"}
|
|
# }
|
|
if call["tool_name"] == "search_logs":
|
|
rows = db.query("logs", filters={"user_id": call["arguments"]["user_id"]})
|
|
return {"results": rows}
|
|
return {"error": "unknown tool"}
|
|
```
|
|
|
|
- Return value is serialized to JSON and returned as the tool result
|
|
- All sandbox modules (including `db`) are available per granted permissions
|
|
- No additional permission is required beyond package being `active`
|
|
|
|
### Multi-File Starlark Packages (v0.38.0)
|
|
|
|
Starlark packages support `load()` for splitting code across multiple
|
|
files. The entry point script is `script.star` (or the `entry_point`
|
|
manifest field). Submodules live in `star/`.
|
|
|
|
**Archive structure:**
|
|
|
|
```
|
|
my-extension/
|
|
├── manifest.json ← metadata only (no _starlark_script)
|
|
├── script.star ← entry point (required for starlark tier)
|
|
├── star/ ← optional submodules
|
|
│ ├── auth.star
|
|
│ ├── repos.star
|
|
│ └── helpers.star
|
|
├── js/ ← optional surface assets
|
|
└── css/
|
|
```
|
|
|
|
**Manifest `entry_point` field (optional):**
|
|
|
|
```json
|
|
{
|
|
"tier": "starlark",
|
|
"entry_point": "script.star"
|
|
}
|
|
```
|
|
|
|
Default is `script.star`. The installer validates the entry point exists
|
|
in the archive for starlark-tier packages (returns 400 if missing).
|
|
|
|
**`load()` in Starlark scripts:**
|
|
|
|
```python
|
|
# script.star
|
|
load("star/auth.star", "auth_headers")
|
|
load("star/repos.star", "get_repos")
|
|
|
|
def on_request(req):
|
|
headers = auth_headers(conn)
|
|
repos = get_repos(conn)
|
|
return {"status": 200, "body": json.encode(repos)}
|
|
```
|
|
|
|
**Constraints:**
|
|
- Package-scoped: can only load files within the package's own directory
|
|
- `.star` files only — cannot load `.js`, `.json`, etc.
|
|
- Path traversal (`..`) and absolute paths are rejected
|
|
- Circular dependencies are detected and rejected
|
|
- Loaded files get the same injected modules (`db`, `http`, etc.) as the entry point
|
|
- All loaded files share the same step limit budget (1M ops total)
|
|
|
|
**Backward compatibility:** Existing packages with `_starlark_script`
|
|
in their manifest continue to work. The runner tries disk first, then
|
|
falls back to the manifest field. Reinstalling extracts `script.star`
|
|
to disk.
|
|
|
|
**Install errors:**
|
|
- `400` — `starlark package missing entry point "script.star"` (or
|
|
custom `entry_point` value)
|
|
|
|
## 5. Extension Connections (v0.38.1)
|
|
|
|
Scoped credential management for extensions that integrate with
|
|
external services. Same scope/resolution pattern as provider configs
|
|
(personal → team → global).
|
|
|
|
### Personal Connections
|
|
|
|
**Auth:** Authenticated user
|
|
|
|
```
|
|
GET /connections → {"data": [...connection summaries]}
|
|
POST /connections ← {"type","package_id","name","config"} → {"id","type","name"}
|
|
GET /connections/:id → connection summary (owned only)
|
|
PUT /connections/:id ← {"name?","config?"} → {"message":"connection updated"}
|
|
DELETE /connections/:id → {"message":"connection deleted"}
|
|
GET /connections/resolve → resolved connection (decrypted config)
|
|
?type=gitea&name=Work+Gitea
|
|
```
|
|
|
|
### Team Connections
|
|
|
|
**Auth:** Team admin (RequireTeamAdmin middleware)
|
|
|
|
```
|
|
GET /teams/:teamId/connections → {"data": [...connection summaries]}
|
|
POST /teams/:teamId/connections ← {"type","package_id","name","config"} → {"id","type","name"}
|
|
PUT /teams/:teamId/connections/:id ← {"name?","config?"} → {"message":"connection updated"}
|
|
DELETE /teams/:teamId/connections/:id → {"message":"connection deleted"}
|
|
```
|
|
|
|
### Admin (Global) Connections
|
|
|
|
**Auth:** Admin
|
|
|
|
```
|
|
GET /admin/connections → {"data": [...connection summaries]}
|
|
POST /admin/connections ← {"type","package_id","name","config"} → {"id","type","name"}
|
|
PUT /admin/connections/:id ← {"name?","config?"} → {"message":"connection updated"}
|
|
DELETE /admin/connections/:id → {"message":"connection deleted"}
|
|
```
|
|
|
|
**Connection summary** (list/get responses — secrets masked):
|
|
```json
|
|
{
|
|
"id": "uuid", "type": "gitea", "package_id": "git-board",
|
|
"scope": "personal", "name": "Work Gitea",
|
|
"is_active": true, "has_config": true,
|
|
"created_at": "2026-03-25T...", "updated_at": "2026-03-25T..."
|
|
}
|
|
```
|
|
|
|
**Scope resolution:** `GET /connections/resolve?type=gitea` resolves
|
|
via personal → team → global chain. Returns full connection with
|
|
decrypted config. Optional `name` parameter for named resolution.
|
|
|
|
**Config encryption:** Fields with `type: "secret"` in the package
|
|
manifest's `connections[].fields` are encrypted at rest using the
|
|
same vault pattern as provider API keys. The handler layer encrypts
|
|
on write and decrypts on read. The store is encryption-agnostic.
|
|
|
|
**Unique constraint:** `(type, scope, owner_id, name)` — a user cannot
|
|
have two connections of the same type with the same name.
|
|
|
|
**Starlark module:** Extensions with `connections.read` permission
|
|
get a `connections` module:
|
|
```python
|
|
conn = connections.get("gitea") # scope chain
|
|
conn = connections.get("gitea", name="Work Gitea") # named
|
|
all = connections.list("gitea") # all accessible
|
|
```
|
|
|
|
Each returned dict contains `id`, `type`, `name`, `scope` plus all
|
|
config fields with secrets decrypted.
|
|
|
|
---
|
|
|
|
### Builtin Seeding
|
|
|
|
On startup, `SeedBuiltinExtensions()` scans `extensions/builtin/`
|
|
for subdirectories containing `manifest.json` + `script.js`. Each
|
|
is upserted as a system extension:
|
|
- **New ext_id** → `Create` with `is_system: true`
|
|
- **Same version** → skip (idempotent)
|
|
- **Different version** → `Update` manifest, name, description, author
|
|
|
|
---
|