All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m47s
CI/CD / test-sqlite (pull_request) Successful in 3m11s
CI/CD / build-and-deploy (pull_request) Successful in 1m26s
ROADMAP.md: v0.14.x expanded from 3→6 versions (sidecar registry, capabilities, event bus, manifest, reference sidecar, user sidecars). v0.15.x split out as polish+stability (dialog audit, migration CLI, schema freeze). v1.0.0 gate adds sidecar criteria. Design decisions log updated with sidecar rationale. New docs: DESIGN-sidecar-v014x.md (full sidecar design), ROADMAP-v010x-v100.md (detailed version plan through 1.0). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
923 lines
33 KiB
Markdown
923 lines
33 KiB
Markdown
# DESIGN: Sidecar Tier — v0.14.x
|
||
|
||
## Status: Proposed
|
||
|
||
## Problem
|
||
|
||
Starlark is the right sandbox for most extension logic: declarative,
|
||
safe, fast to start, no deployment pipeline. But some workloads cannot
|
||
run in Starlark:
|
||
|
||
- **ML inference** — Python + PyTorch/TF model loading, GPU access.
|
||
- **Media transcoding** — ffmpeg, ImageMagick, Whisper.
|
||
- **Language servers** — LSP processes for code-workspace.
|
||
- **Local git** — clone, diff, merge operations on workspace directories.
|
||
- **Custom runtimes** — anything needing system libraries, native code,
|
||
or long-running processes.
|
||
- **Personal local bridges** — a user's laptop exposing filesystem,
|
||
terminal, clipboard, or local compute to the Armature instance.
|
||
|
||
Today, the `http` module lets Starlark call external APIs (`api.http`
|
||
permission, domain allowlists, SSRF protection). This covers cloud
|
||
services (OpenAI, S3, external webhooks) but not **co-located processes**
|
||
that need access to Armature's data, events, and workspace filesystem,
|
||
nor **personal processes** on a user's own machine.
|
||
|
||
The sidecar tier bridges this gap: out-of-process extensions that
|
||
connect inward to the kernel, authenticate, register capabilities, and
|
||
participate in the extension ecosystem as first-class citizens — at both
|
||
the **instance level** (shared infrastructure) and the **user level**
|
||
(personal local tools).
|
||
|
||
**If done wrong, the consequences are severe:**
|
||
|
||
- Wrong auth model → arbitrary processes access kernel APIs.
|
||
- Wrong lifecycle → resource leaks, zombie processes, orphaned
|
||
registrations.
|
||
- Wrong API surface → every sidecar extension inherits the debt.
|
||
- Wrong isolation → a runaway sidecar takes down the cluster.
|
||
- Wrong discovery → fragile deployments that break on restart.
|
||
- Wrong scoping → user sidecars see other users' data.
|
||
|
||
This is invisible infrastructure. Users never see it. But every
|
||
extension that needs native code depends on the contract being right.
|
||
|
||
## Non-Goals
|
||
|
||
- **Kernel manages sidecar processes.** The kernel does not start, stop,
|
||
or restart sidecars. That's the deployment layer's job (K8s, Docker
|
||
Compose, systemd, or a human running a binary). The kernel discovers
|
||
sidecars that connect to it.
|
||
- **Sidecar marketplace.** Sidecar packages are installed like any other
|
||
`.pkg` file. The sidecar binary ships separately (container image,
|
||
standalone binary, pip package). The manifest declares the sidecar
|
||
endpoint; the operator deploys it.
|
||
- **Arbitrary bidirectional streaming.** Sidecars communicate via
|
||
HTTP/JSON request-response and WebSocket events. No gRPC, no custom
|
||
binary protocols. KISS.
|
||
- **Hot code reload.** Sidecar updates require process restart. The
|
||
kernel detects the reconnection and re-registers capabilities.
|
||
- **Multi-tenant sidecar isolation.** Instance-level sidecars run at the
|
||
instance level, not per-user. User-level isolation is handled by
|
||
user sidecars (v0.14.5), which are scoped to the owning user's
|
||
identity.
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
### Connect-Inward Model
|
||
|
||
The defining property: **sidecars connect TO the kernel, not the other
|
||
way around.** The kernel never reaches outward to discover or contact
|
||
a sidecar. This eliminates:
|
||
|
||
- K8s RBAC for the kernel to discover pods.
|
||
- Service mesh configuration.
|
||
- DNS-based service discovery.
|
||
- Any notion of the kernel "managing" external processes.
|
||
|
||
A sidecar is just a process that knows the kernel's address and has
|
||
credentials to authenticate. It could be a container in the same pod, a
|
||
separate deployment, a systemd service on the same machine, or a process
|
||
on a developer's laptop connected via tunnel.
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────┐
|
||
│ Deployment Layer (K8s / Docker / systemd / manual) │
|
||
│ │
|
||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||
│ │ instance │ │ instance │ │ instance │ │
|
||
│ │ sidecar: │ │ sidecar: │ │ sidecar: │ │
|
||
│ │ ml-infer │ │ ffmpeg │ │ git-ops │ │
|
||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||
│ │ │ │ │
|
||
│ │ connect inward │ │
|
||
│ ▼ ▼ ▼ │
|
||
│ ┌──────────────────────────────────────────┐ │
|
||
│ │ Armature Kernel │ │
|
||
│ │ ┌─────────────────────────────────────┐ │ │
|
||
│ │ │ Sidecar Registry (PG) │ │ │
|
||
│ │ │ ml-infer: instance, 3 caps │ │ │
|
||
│ │ │ ffmpeg: instance, 2 caps │ │ │
|
||
│ │ │ git-ops: instance, 4 caps │ │ │
|
||
│ │ │ jeff-local: user:jeff, 3 caps │ │ │
|
||
│ │ └─────────────────────────────────────┘ │ │
|
||
│ └────────────────────────────────▲─────────┘ │
|
||
│ │ │
|
||
└───────────────────────────────────┼──────────────────┘
|
||
│ connect inward
|
||
┌─────┴──────┐
|
||
│ user │
|
||
│ sidecar: │
|
||
│ jeff-local │
|
||
│ (laptop) │
|
||
└────────────┘
|
||
```
|
||
|
||
### Two Scopes
|
||
|
||
**Instance sidecars** — shared infrastructure. Deployed by the operator.
|
||
Any user's requests can invoke their capabilities. An ML inference
|
||
sidecar, a transcoding service, a git operations worker.
|
||
|
||
**User sidecars** — personal processes. Run by an individual user on
|
||
their own machine. Only the owning user's requests can invoke their
|
||
capabilities. A local file bridge, a personal Ollama instance, a
|
||
script that reacts to Armature events.
|
||
|
||
Both use the same connect-inward mechanism, same registration flow,
|
||
same capability contract. The difference is the identity binding and
|
||
access scoping.
|
||
|
||
### Two Authentication Modes
|
||
|
||
#### Mode A: Registration Token
|
||
|
||
For simple deployments (Docker Compose, single machine, dev/test).
|
||
|
||
1. Admin generates an instance sidecar token:
|
||
`armature sidecar token create --package ml-inference`
|
||
User generates a personal sidecar token (from settings UI or CLI):
|
||
`armature sidecar token create --personal`
|
||
2. Token is a signed JWT with claims: `{package_id, scope, user_id, exp}`.
|
||
Instance tokens: `scope: "instance"`, `user_id: null`.
|
||
User tokens: `scope: "user"`, `user_id: "<owner>"`.
|
||
3. Sidecar starts with the token as env: `ARMATURE_TOKEN=ey...`
|
||
4. Sidecar calls `POST /api/v1/sidecar/register`.
|
||
5. Kernel validates JWT, creates registry entry with appropriate scope.
|
||
|
||
#### Mode B: mTLS
|
||
|
||
For production deployments with certificate infrastructure.
|
||
|
||
1. Certificate CN encodes identity:
|
||
Instance: `sidecar:ml-inference`
|
||
User: `sidecar-user:jeff:local-bridge`
|
||
2. Kernel's `MTLSNativeProvider` parses the CN prefix and resolves to
|
||
the appropriate sidecar identity.
|
||
|
||
Both modes produce the same internal identity: a `SidecarIdentity`
|
||
struct with `{package_id, scope, user_id, node_id, registered_at}`.
|
||
|
||
### Sidecar Registry
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS sidecar_registry (
|
||
sidecar_id TEXT PRIMARY KEY,
|
||
package_id TEXT NOT NULL,
|
||
scope TEXT NOT NULL DEFAULT 'instance',
|
||
user_id TEXT,
|
||
endpoint TEXT NOT NULL,
|
||
heartbeat TIMESTAMPTZ DEFAULT now(),
|
||
capabilities JSONB DEFAULT '[]',
|
||
stats JSONB DEFAULT '{}',
|
||
registered_at TIMESTAMPTZ DEFAULT now()
|
||
);
|
||
|
||
CREATE INDEX idx_sidecar_pkg ON sidecar_registry (package_id);
|
||
CREATE INDEX idx_sidecar_user ON sidecar_registry (user_id)
|
||
WHERE user_id IS NOT NULL;
|
||
```
|
||
|
||
`scope` is `'instance'` or `'user'`. `user_id` is NULL for instance
|
||
sidecars, set for user sidecars.
|
||
|
||
**Lifecycle (mirrors cluster registry):**
|
||
|
||
- **Register:** `POST /api/v1/sidecar/register` with capability
|
||
manifest. Kernel creates/updates registry row.
|
||
- **Heartbeat:** `POST /api/v1/sidecar/heartbeat` every N seconds.
|
||
- **Sweep:** Kernel's heartbeat loop sweeps stale sidecar entries
|
||
alongside node entries. 3× heartbeat interval = stale.
|
||
- **Self-eviction:** Heartbeat returns 404 → re-register or exit.
|
||
- **Deregister:** `DELETE /api/v1/sidecar/deregister` on graceful
|
||
shutdown.
|
||
|
||
### Capability Registration
|
||
|
||
On registration, the sidecar declares capabilities:
|
||
|
||
```json
|
||
POST /api/v1/sidecar/register
|
||
{
|
||
"package_id": "ml-inference",
|
||
"endpoint": "http://ml-inference:8080",
|
||
"capabilities": [
|
||
{
|
||
"name": "embed",
|
||
"description": "Generate text embeddings",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"text": { "type": "string" },
|
||
"model": { "type": "string", "default": "all-MiniLM-L6-v2" }
|
||
},
|
||
"required": ["text"]
|
||
},
|
||
"timeout_seconds": 30
|
||
},
|
||
{
|
||
"name": "classify",
|
||
"description": "Zero-shot text classification",
|
||
"input_schema": { ... },
|
||
"timeout_seconds": 60
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### Sidecar SDK (Starlark)
|
||
|
||
New `sidecar` Starlark module. Permission: `sidecar.call`.
|
||
|
||
```python
|
||
# Call an instance sidecar capability
|
||
result = sidecar.call("ml-inference", "embed", {"text": "Hello"})
|
||
|
||
# Check availability
|
||
available = sidecar.available("ml-inference")
|
||
|
||
# List capabilities
|
||
caps = sidecar.capabilities("ml-inference")
|
||
```
|
||
|
||
User sidecar calls are routed automatically. When a request originates
|
||
from user Jeff, `sidecar.call("user-bridge", "fs.search", {...})` hits
|
||
Jeff's personal sidecar. The same call from user Alice would hit Alice's
|
||
personal `user-bridge` sidecar (or fail if she doesn't have one). The
|
||
Starlark caller doesn't specify the user — the kernel resolves it from
|
||
the request context.
|
||
|
||
### Sidecar SDK (Frontend)
|
||
|
||
```js
|
||
const result = await sw.sidecar.call('ml-inference', 'embed', {
|
||
text: 'Hello, world'
|
||
});
|
||
const available = await sw.sidecar.available('user-bridge');
|
||
```
|
||
|
||
Frontend calls go through the kernel API. The kernel proxies to the
|
||
appropriate sidecar based on scope + user identity.
|
||
|
||
### Sidecar API Contract (Inbound to Kernel)
|
||
|
||
| Endpoint | Purpose | Auth |
|
||
|----------|---------|------|
|
||
| `POST /api/v1/sidecar/register` | Register + declare capabilities | Token or mTLS |
|
||
| `POST /api/v1/sidecar/heartbeat` | Heartbeat + stats | Token or mTLS |
|
||
| `DELETE /api/v1/sidecar/deregister` | Graceful shutdown | Token or mTLS |
|
||
| `GET /api/v1/ext/{pkg}/*` | Read extension data (scoped) | Token or mTLS |
|
||
| `POST /api/v1/ext/{pkg}/*` | Write extension data (scoped) | Token or mTLS |
|
||
| `POST /api/v1/sidecar/emit` | Emit events | Token or mTLS |
|
||
| `GET /api/v1/sidecar/subscribe` | WebSocket event subscription | Token or mTLS |
|
||
| `POST /api/v1/sidecar/lib/{pkg}/{fn}` | Cross-package function call | Token or mTLS |
|
||
|
||
**Scoping rules:**
|
||
|
||
- Instance sidecars: can access `/api/v1/ext/{own_package}/*` only.
|
||
- User sidecars: can access `/api/v1/ext/{own_package}/*` scoped to
|
||
the owning user's data within that package. The sidecar sees the
|
||
same data the user would see through the UI.
|
||
- Event emission scoped to the sidecar's package prefix.
|
||
|
||
### Sidecar API Contract (Outbound from Kernel)
|
||
|
||
| Endpoint | Purpose |
|
||
|----------|---------|
|
||
| `POST {sidecar_endpoint}/api/v1/exec/{capability}` | Execute a capability |
|
||
| `GET {sidecar_endpoint}/api/v1/health` | Health check |
|
||
|
||
The `/exec/{capability}` endpoint receives:
|
||
|
||
```json
|
||
{
|
||
"input": { ... },
|
||
"context": {
|
||
"user_id": "...",
|
||
"package_id": "...",
|
||
"request_id": "..."
|
||
}
|
||
}
|
||
```
|
||
|
||
Returns:
|
||
|
||
```json
|
||
{
|
||
"output": { ... },
|
||
"error": null
|
||
}
|
||
```
|
||
|
||
### Resource Limits
|
||
|
||
What the kernel enforces at the proxy layer:
|
||
|
||
- **Timeout:** Per-capability, from capability declaration.
|
||
- **Request rate:** Per-sidecar, configurable in admin.
|
||
- **Response size:** Default 10MB, configurable per-sidecar.
|
||
- **Concurrent calls:** Default 10, configurable per-sidecar.
|
||
|
||
User sidecars have separate (typically lower) defaults:
|
||
|
||
- **User sidecar rate limit:** Default 30/min (vs 100/min instance).
|
||
- **User sidecar concurrency:** Default 3 (vs 10 instance).
|
||
- **User sidecar response size:** Default 5MB (vs 10MB instance).
|
||
|
||
Admin can adjust user sidecar defaults globally. Individual users
|
||
cannot override admin limits.
|
||
|
||
### Manifest Integration
|
||
|
||
Instance sidecar manifest:
|
||
|
||
```json
|
||
{
|
||
"id": "ml-inference",
|
||
"type": "full",
|
||
"tier": "sidecar",
|
||
"version": "1.0.0",
|
||
|
||
"sidecar": {
|
||
"image": "gobha/armature-ml-inference:latest",
|
||
"required": true,
|
||
"health_endpoint": "/api/v1/health",
|
||
"env_hints": {
|
||
"ARMATURE_URL": "Kernel URL (auto-populated)",
|
||
"ARMATURE_TOKEN": "Registration token",
|
||
"MODEL_PATH": "Path to model files"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
User sidecar manifest (installed as a regular package, sidecar runs
|
||
on user's machine):
|
||
|
||
```json
|
||
{
|
||
"id": "user-bridge",
|
||
"type": "full",
|
||
"tier": "sidecar",
|
||
"version": "1.0.0",
|
||
|
||
"sidecar": {
|
||
"scope": "user",
|
||
"required": false,
|
||
"download_url": "https://github.com/armature/user-bridge/releases",
|
||
"env_hints": {
|
||
"ARMATURE_URL": "Your Armature instance URL",
|
||
"ARMATURE_TOKEN": "Generate from Settings → Sidecars"
|
||
}
|
||
},
|
||
|
||
"surfaces": ["/user-bridge/settings"]
|
||
}
|
||
```
|
||
|
||
`sidecar.scope: "user"` tells the kernel this is a user sidecar
|
||
package. The package installs normally (admin or self-install depending
|
||
on permissions), but the sidecar binary runs on the user's machine.
|
||
|
||
### Tool Meta-Tool Integration
|
||
|
||
When `llm-bridge` assembles tools for a completion:
|
||
|
||
1. Instance-level actions from `sw.actions.list()` → available to all.
|
||
2. Instance sidecar capabilities → available to all.
|
||
3. **Requesting user's personal sidecar capabilities** → available to
|
||
that user only.
|
||
|
||
This means different users get different tool sets. Jeff has
|
||
`user-bridge` running with `fs.search`, `terminal.exec`, and
|
||
`clipboard.get`. Alice doesn't. When Jeff asks "Hey Max, find the auth
|
||
file on my desktop," Max has `user-bridge.fs.search` in his tool set.
|
||
When Alice asks the same, that tool doesn't exist — Max tells her he
|
||
can't access her local files.
|
||
|
||
The same AI persona, different capabilities per user, zero configuration.
|
||
|
||
### Admin UI
|
||
|
||
**Instance sidecar management:**
|
||
- Sidecar health status per registered instance sidecar.
|
||
- Capabilities list with call counts, error rates, latency.
|
||
- Token management: generate, revoke, list.
|
||
- Per-sidecar rate limit and concurrency configuration.
|
||
|
||
**User sidecar admin controls:**
|
||
- Permission: `sidecar.connect_personal` — admin grants to specific
|
||
users or groups.
|
||
- Capability allowlist for user sidecars — admin restricts which
|
||
capabilities user sidecars can declare.
|
||
- Global user sidecar resource limits (rate, concurrency, response
|
||
size).
|
||
- Monitoring: list of all connected user sidecars with owner, endpoint,
|
||
capabilities, last heartbeat.
|
||
- Kill switch: admin can revoke any user sidecar token or sweep a
|
||
specific user's sidecars.
|
||
|
||
**User settings (for users with `sidecar.connect_personal`):**
|
||
- Generate personal sidecar token.
|
||
- List personal connected sidecars with health status.
|
||
- Revoke own tokens.
|
||
- Download link / instructions for user sidecar binaries (from package
|
||
manifest `download_url`).
|
||
|
||
### Monitoring
|
||
|
||
Kernel-side metrics:
|
||
|
||
- `sidecar_call_total{package, capability, scope, status}` — counter.
|
||
- `sidecar_call_duration_seconds{package, capability, scope}` — histogram.
|
||
- `sidecar_heartbeat_age_seconds{package, scope}` — gauge.
|
||
- `sidecar_active{package, scope}` — gauge.
|
||
- `sidecar_user_count` — gauge (number of connected user sidecars).
|
||
|
||
---
|
||
|
||
## User Sidecar Use Cases
|
||
|
||
### Local Bridge (Desktop Commander Pattern)
|
||
|
||
A thin agent on the user's laptop that exposes local capabilities:
|
||
|
||
```json
|
||
{
|
||
"capabilities": [
|
||
{
|
||
"name": "fs.search",
|
||
"description": "Search local filesystem by filename or content",
|
||
"input_schema": {
|
||
"properties": {
|
||
"query": { "type": "string" },
|
||
"path": { "type": "string", "default": "~" },
|
||
"content_search": { "type": "boolean", "default": false }
|
||
},
|
||
"required": ["query"]
|
||
},
|
||
"timeout_seconds": 15
|
||
},
|
||
{
|
||
"name": "fs.read",
|
||
"description": "Read a local file",
|
||
"input_schema": {
|
||
"properties": {
|
||
"path": { "type": "string" },
|
||
"max_bytes": { "type": "integer", "default": 1048576 }
|
||
},
|
||
"required": ["path"]
|
||
},
|
||
"timeout_seconds": 10
|
||
},
|
||
{
|
||
"name": "terminal.exec",
|
||
"description": "Execute a shell command",
|
||
"input_schema": {
|
||
"properties": {
|
||
"command": { "type": "string" },
|
||
"cwd": { "type": "string", "default": "~" },
|
||
"timeout": { "type": "integer", "default": 30 }
|
||
},
|
||
"required": ["command"]
|
||
},
|
||
"timeout_seconds": 60
|
||
},
|
||
{
|
||
"name": "clipboard.get",
|
||
"description": "Read clipboard contents",
|
||
"input_schema": {},
|
||
"timeout_seconds": 5
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**Tool meta-tool flow:**
|
||
|
||
```
|
||
User: "Hey Max, find the database migration script I was
|
||
working on yesterday and add it to my notes"
|
||
|
||
llm-bridge assembles (for this user):
|
||
Layer 5 tools: [...instance tools..., user-bridge.fs.search,
|
||
user-bridge.fs.read, notes.create, ...]
|
||
|
||
LLM:
|
||
1. Tool: user-bridge.fs.search({query: "migration", path: "~/code"})
|
||
2. Result: [{path: "~/code/armature/migrations/015_sidecar.sql", ...}]
|
||
3. Tool: user-bridge.fs.read({path: "~/code/armature/migrations/015_sidecar.sql"})
|
||
4. Result: {content: "CREATE TABLE IF NOT EXISTS sidecar_registry..."}
|
||
5. Tool: notes.create({title: "Migration 015 - Sidecar Registry",
|
||
body: "```sql\nCREATE TABLE..."})
|
||
6. Text: "Found your sidecar migration script and saved it to Notes."
|
||
```
|
||
|
||
Three extensions orchestrated: user's local filesystem, instance-level
|
||
notes, all through the same tool meta-tool pattern.
|
||
|
||
### Personal Compute (Local Ollama)
|
||
|
||
User runs Ollama on their workstation with a GPU:
|
||
|
||
```json
|
||
{
|
||
"capabilities": [
|
||
{
|
||
"name": "complete",
|
||
"description": "LLM completion via local Ollama",
|
||
"input_schema": {
|
||
"properties": {
|
||
"prompt": { "type": "string" },
|
||
"model": { "type": "string", "default": "llama3" }
|
||
},
|
||
"required": ["prompt"]
|
||
},
|
||
"timeout_seconds": 120
|
||
},
|
||
{
|
||
"name": "embed",
|
||
"description": "Local embedding via Ollama",
|
||
"input_schema": {
|
||
"properties": {
|
||
"text": { "type": "string" },
|
||
"model": { "type": "string", "default": "nomic-embed-text" }
|
||
},
|
||
"required": ["text"]
|
||
},
|
||
"timeout_seconds": 30
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
`llm-bridge` can be configured (per-user setting or persona preference)
|
||
to route completions through the user's local Ollama instead of the
|
||
instance-level provider. BYOK taken to the extreme — bring your own
|
||
hardware.
|
||
|
||
### Personal Automation
|
||
|
||
A script that reacts to Armature events:
|
||
|
||
```python
|
||
# Personal deploy watcher — connects to Armature, subscribes to
|
||
# events in the #deploys conversation, runs kubectl locally
|
||
|
||
ws = connect(f'{KERNEL_URL}/api/v1/sidecar/subscribe',
|
||
token=TOKEN)
|
||
|
||
for event in ws:
|
||
if event['type'] == 'chat.message' and 'deploy' in event['content']:
|
||
result = subprocess.run(['kubectl', 'get', 'pods', '-n', 'prod'],
|
||
capture_output=True, text=True)
|
||
# Post result back to chat
|
||
requests.post(f'{KERNEL_URL}/api/v1/sidecar/lib/chat-core/send',
|
||
json={'conversation_id': event['conversation_id'],
|
||
'content': f'```\n{result.stdout}\n```'},
|
||
headers={'Authorization': f'Bearer {TOKEN}'})
|
||
```
|
||
|
||
Personal workflow glue. No instance-level deployment required.
|
||
|
||
---
|
||
|
||
## RBAC for User Sidecars
|
||
|
||
Three-layer permission model:
|
||
|
||
### Layer 1: Admin Controls WHO
|
||
|
||
New permission: `sidecar.connect_personal`.
|
||
|
||
Admin grants to specific users, groups, or roles. Most users don't
|
||
need it. Developers, power users, AI-heavy workflows — they get the
|
||
permission.
|
||
|
||
Default: not granted. Opt-in only.
|
||
|
||
### Layer 2: Admin Controls WHAT
|
||
|
||
**Capability allowlist for user sidecars.**
|
||
|
||
Admin setting: `SIDECAR_USER_ALLOWED_CAPABILITIES`
|
||
|
||
Default: `["fs.search", "fs.read", "clipboard.get"]` — safe read-only
|
||
operations.
|
||
|
||
To enable terminal access: admin adds `"terminal.exec"` to the
|
||
allowlist. This is a deliberate escalation requiring admin action.
|
||
|
||
A user sidecar that attempts to register a capability not on the
|
||
allowlist gets a clear error: "Capability 'terminal.exec' not permitted
|
||
for user sidecars. Contact your administrator."
|
||
|
||
### Layer 3: User Controls VISIBILITY
|
||
|
||
User sidecar capabilities are **private by default** — only the owning
|
||
user's requests invoke them.
|
||
|
||
Optional: user can share a specific capability with their team via the
|
||
settings UI. Shared capabilities appear in team members' tool sets
|
||
(with attribution: "via Jeff's local bridge").
|
||
|
||
User sidecars are never instance-wide. That's what instance sidecars
|
||
are for.
|
||
|
||
---
|
||
|
||
## Security Considerations
|
||
|
||
### Instance Sidecars
|
||
|
||
1. **Compromised binary** → can read/write own package data, call
|
||
exported functions. Mitigation: package-scoped access, audit logging,
|
||
token revocation.
|
||
2. **Token theft** → impersonate sidecar. Mitigation: package-scoped
|
||
tokens, mTLS eliminates theft, revocable.
|
||
3. **SSRF via sidecar.call** → Mitigation: endpoints validated at
|
||
registration, no runtime changes.
|
||
4. **Resource exhaustion** → Mitigation: per-sidecar concurrency and
|
||
rate limits, timeout enforcement, response size limits.
|
||
5. **Privilege escalation via lib.require** → Mitigation: calls
|
||
attributed to sidecar identity, target permission checks apply.
|
||
|
||
### User Sidecars (Additional Concerns)
|
||
|
||
6. **User deploys malicious sidecar** → registers capabilities that
|
||
exfiltrate data when called. Mitigation: capability allowlist (admin
|
||
controls what capabilities can be declared), private-by-default
|
||
(only the user's own requests trigger it — they're exfiltrating
|
||
from themselves).
|
||
|
||
7. **User shares malicious capability with team** → team members
|
||
invoke it, sidecar captures their request data. Mitigation: shared
|
||
capabilities are clearly attributed ("via Jeff's local bridge").
|
||
Admin can disable capability sharing entirely. Shared capabilities
|
||
run with the CALLING user's permissions, not the sidecar owner's.
|
||
|
||
8. **User sidecar as pivot** → user's laptop compromised, attacker uses
|
||
connected sidecar to access Armature data. Mitigation: user sidecar
|
||
can only access data the user could already access through the UI.
|
||
No privilege escalation. User can revoke own tokens. Admin kill
|
||
switch.
|
||
|
||
9. **Stale user sidecars** → user's laptop goes offline, sidecar
|
||
becomes stale. Mitigation: same sweep mechanism as instance
|
||
sidecars. 3× heartbeat interval = swept. User reconnects when
|
||
laptop comes back online.
|
||
|
||
### Principle of Least Privilege
|
||
|
||
- Instance sidecar: access own package data only, middleware-enforced.
|
||
- User sidecar: access own package data scoped to owning user only.
|
||
- Event emission scoped to package prefix.
|
||
- Capability allowlist for user sidecars (admin-controlled).
|
||
- Shared capabilities run with caller's permissions.
|
||
|
||
---
|
||
|
||
## Version Plan
|
||
|
||
### v0.14.0 — Sidecar Registry + Auth
|
||
|
||
**Goal:** Sidecars can register, authenticate, and heartbeat.
|
||
|
||
- Sidecar registry table with `scope` and `user_id` columns.
|
||
- Registration endpoint with JWT token validation.
|
||
- Heartbeat + sweep integration.
|
||
- Token generation (instance tokens via admin, user tokens via settings).
|
||
- `SIDECAR_AUTH_MODE` config: `token` or `mtls`.
|
||
- mTLS auth: `sidecar:` and `sidecar-user:` CN prefixes.
|
||
- Admin UI: sidecar health on packages page.
|
||
|
||
**Instance sidecars only in this version.** User sidecar plumbing
|
||
exists in the schema but user token generation and RBAC gating land
|
||
in v0.14.5.
|
||
|
||
### v0.14.1 — Capability Registration + Execution
|
||
|
||
**Goal:** Sidecars declare capabilities. Starlark extensions call them.
|
||
|
||
- Capability manifest in registration payload.
|
||
- `sidecar` Starlark module: `call()`, `available()`, `capabilities()`.
|
||
- Kernel proxy: `sidecar.call()` → `POST {endpoint}/exec/{cap}`.
|
||
- Input validation against declared schema.
|
||
- Timeout, response size, concurrent call limits.
|
||
- Error propagation.
|
||
- `sidecar.call` permission.
|
||
- Audit logging.
|
||
|
||
### v0.14.2 — Kernel API Access + Event Bus
|
||
|
||
**Goal:** Sidecars read/write their own data and participate in events.
|
||
|
||
- Sidecar middleware: authenticate, scope to own package routes.
|
||
- `/api/v1/sidecar/lib/{pkg}/{fn}` — cross-package function calls.
|
||
- `/api/v1/sidecar/emit` — event emission (package-scoped).
|
||
- `/api/v1/sidecar/subscribe` — WebSocket event subscription.
|
||
- Frontend `sw.sidecar` SDK module.
|
||
|
||
### v0.14.3 — Manifest Integration + Admin Polish
|
||
|
||
**Goal:** Sidecar packages are first-class in install/admin flow.
|
||
|
||
- `tier: "sidecar"` manifest support.
|
||
- `sidecar` manifest field (image, required, scope, env_hints,
|
||
download_url).
|
||
- Admin UI: deployment instructions from manifest.
|
||
- Admin UI: capability list with metrics.
|
||
- Admin UI: per-sidecar rate limit configuration.
|
||
- Token management UI.
|
||
- Monitoring metrics.
|
||
|
||
### v0.14.4 — Reference Sidecar + Instance Quality Gate
|
||
|
||
**Goal:** Ship one real instance sidecar. Prove the contract.
|
||
|
||
**`armature-embed`** — minimal Python sidecar running
|
||
sentence-transformers for local embedding generation. One capability:
|
||
`embed(text) → float[]`.
|
||
|
||
`vector-store` calls `sidecar.call('armature-embed', 'embed', {...})`
|
||
for local embedding without an external API. Completes the local-first
|
||
RAG story.
|
||
|
||
**Instance sidecar quality gate:**
|
||
|
||
- Registration, heartbeat, sweep lifecycle tested.
|
||
- Token and mTLS auth both functional.
|
||
- Capability execution with limits enforced.
|
||
- Package-scoped API access enforced.
|
||
- Event emission/subscription functional.
|
||
- `sw.sidecar` frontend module functional.
|
||
- Reference sidecar runs in Docker and K8s.
|
||
- Admin UI complete.
|
||
- Monitoring metrics in Grafana.
|
||
|
||
### v0.14.5 — User Sidecars
|
||
|
||
**Goal:** Users connect personal processes to Armature.
|
||
|
||
- `sidecar.connect_personal` permission (admin-granted).
|
||
- User token generation in Settings → Sidecars.
|
||
- Capability allowlist for user sidecars (admin setting:
|
||
`SIDECAR_USER_ALLOWED_CAPABILITIES`).
|
||
- User sidecar registration with `scope: "user"`, `user_id` binding.
|
||
- Execution routing: `sidecar.call()` resolves user sidecars based
|
||
on request context (calling user gets their own sidecar).
|
||
- Capability sharing: user can share specific capabilities with team.
|
||
Shared caps attributed ("via Jeff's local bridge").
|
||
- Separate resource limits for user sidecars (lower defaults).
|
||
- User settings UI: connected sidecars, token management, sidecar
|
||
instructions from package manifests.
|
||
- Admin UI: user sidecar monitoring, kill switch.
|
||
- Tool meta-tool integration: `llm-bridge` includes user's personal
|
||
sidecar capabilities in tool set assembly.
|
||
|
||
**Reference user sidecar: `user-bridge`** — thin agent binary with
|
||
`fs.search`, `fs.read`, `clipboard.get` capabilities. Published as a
|
||
package with `sidecar.scope: "user"`. Binary as a GitHub release.
|
||
|
||
**User sidecar quality gate:**
|
||
|
||
- RBAC: user without `sidecar.connect_personal` cannot register.
|
||
- Capability allowlist enforced.
|
||
- User A cannot invoke user B's sidecar.
|
||
- Shared capabilities run with caller's permissions.
|
||
- Admin can revoke any user sidecar.
|
||
- Tool meta-tool assembles per-user tool sets correctly.
|
||
- `user-bridge` reference sidecar runs on macOS, Linux, Windows.
|
||
|
||
---
|
||
|
||
## Schema Summary
|
||
|
||
### New tables
|
||
|
||
- `sidecar_registry` — sidecar_id, package_id, scope, user_id,
|
||
endpoint, heartbeat, capabilities (JSON), stats (JSON),
|
||
registered_at. (v0.14.0)
|
||
- `sidecar_tokens` — id, package_id, scope, user_id, token_hash,
|
||
created_by, expires_at, revoked_at. (v0.14.0)
|
||
|
||
---
|
||
|
||
## Configuration
|
||
|
||
| Setting | Default | Purpose |
|
||
|---------|---------|---------|
|
||
| `SIDECAR_AUTH_MODE` | `token` | `token` or `mtls` |
|
||
| `SIDECAR_HEARTBEAT_INTERVAL` | `10s` | Expected heartbeat frequency |
|
||
| `SIDECAR_STALE_THRESHOLD` | `30s` | 3× heartbeat = stale |
|
||
| `SIDECAR_MAX_RESPONSE_SIZE` | `10485760` (10MB) | Instance default |
|
||
| `SIDECAR_DEFAULT_CONCURRENCY` | `10` | Instance default |
|
||
| `SIDECAR_DEFAULT_RATE_LIMIT` | `100/min` | Instance default |
|
||
| `SIDECAR_USER_MAX_RESPONSE_SIZE` | `5242880` (5MB) | User default |
|
||
| `SIDECAR_USER_CONCURRENCY` | `3` | User default |
|
||
| `SIDECAR_USER_RATE_LIMIT` | `30/min` | User default |
|
||
| `SIDECAR_USER_ALLOWED_CAPABILITIES` | `["fs.search","fs.read","clipboard.get"]` | Admin-controlled allowlist |
|
||
|
||
---
|
||
|
||
## Sidecar Author Contract
|
||
|
||
### Minimum Viable Sidecar (Python)
|
||
|
||
```python
|
||
from flask import Flask, request, jsonify
|
||
import requests, os, threading, time
|
||
|
||
app = Flask(__name__)
|
||
KERNEL = os.environ['ARMATURE_URL']
|
||
TOKEN = os.environ['ARMATURE_TOKEN']
|
||
|
||
def register():
|
||
requests.post(f'{KERNEL}/api/v1/sidecar/register',
|
||
json={
|
||
'package_id': 'my-sidecar',
|
||
'endpoint': f'http://localhost:8080',
|
||
'capabilities': [{
|
||
'name': 'echo',
|
||
'description': 'Echo input back',
|
||
'input_schema': {
|
||
'type': 'object',
|
||
'properties': {'text': {'type': 'string'}},
|
||
'required': ['text']
|
||
},
|
||
'timeout_seconds': 10
|
||
}]
|
||
},
|
||
headers={'Authorization': f'Bearer {TOKEN}'})
|
||
|
||
def heartbeat():
|
||
while True:
|
||
time.sleep(10)
|
||
r = requests.post(f'{KERNEL}/api/v1/sidecar/heartbeat',
|
||
json={'stats': {}},
|
||
headers={'Authorization': f'Bearer {TOKEN}'})
|
||
if r.status_code == 404:
|
||
register()
|
||
|
||
@app.route('/api/v1/exec/echo', methods=['POST'])
|
||
def exec_echo():
|
||
return jsonify({
|
||
'output': {'text': request.json['input']['text']},
|
||
'error': None
|
||
})
|
||
|
||
@app.route('/api/v1/health')
|
||
def health():
|
||
return jsonify({'status': 'ok'})
|
||
|
||
register()
|
||
threading.Thread(target=heartbeat, daemon=True).start()
|
||
app.run(port=8080)
|
||
```
|
||
|
||
Four things: register, heartbeat, `/exec/`, `/health`. That's the
|
||
contract.
|
||
|
||
---
|
||
|
||
## Design Decisions
|
||
|
||
| Decision | Rationale |
|
||
|----------|-----------|
|
||
| Connect-inward | Eliminates K8s RBAC, service mesh, DNS discovery. Works on any deployment target. |
|
||
| Two auth modes | Token for simple. mTLS for production. Same internal identity. |
|
||
| Cluster registry pattern | Proven heartbeat/sweep. No new coordination mechanism. |
|
||
| HTTP/JSON, no gRPC | KISS. 4-endpoint contract. Every language has HTTP. |
|
||
| Kernel proxies all calls | Auth, rate limits, audit, timeouts at single control point. |
|
||
| Package-scoped access | Compromised sidecar can't read other packages. |
|
||
| User sidecars as v0.14.5 | Prove instance contract first (v0.14.0–v0.14.4), then extend to users. Same mechanism, new scope. |
|
||
| Three-layer user RBAC | Admin controls who (permission), what (capability allowlist), user controls visibility (private/shared). Defense in depth. |
|
||
| Capability allowlist for user sidecars | Admin decides what user processes can expose. `terminal.exec` is a deliberate escalation. |
|
||
| Private-by-default user capabilities | User sidecar can't affect other users unless explicitly shared. |
|
||
| Shared caps run with caller's perms | Prevents privilege escalation through shared capabilities. |
|
||
| Reference instance sidecar: embedding | Simpler than LLM. Proves contract. Completes local RAG story. |
|
||
| Reference user sidecar: file bridge | Most compelling user sidecar demo. "Find a file on my laptop and save it to notes" is visceral. |
|
||
| Lower resource limits for user sidecars | User sidecars are less trusted (running on personal devices). Lower defaults, admin can't be overridden by users. |
|
||
| Separate from polish (v0.15.x) | Sidecar is security-critical infrastructure. Polish is quality-of-life. Don't mix. |
|
||
|
||
---
|
||
|
||
## Future Considerations
|
||
|
||
- **Streaming responses.** LLM sidecar needs token streaming. SSE or
|
||
chunked transfer. Deferred — batch covers embedding, classification,
|
||
transcoding.
|
||
- **SDK libraries.** Thin wrappers in Python, Go, Node, Rust for
|
||
registration + heartbeat + parsing. Convenience, not requirement.
|
||
- **Auto-scaling.** Multiple instances of same sidecar, kernel
|
||
load-balances. No schema change needed — registry supports multiple
|
||
rows per package.
|
||
- **GPU detection.** Kernel capability for manifest negotiation.
|
||
- **Sidecar-to-sidecar.** Direct calls for performance pipelines.
|
||
Currently goes through kernel.
|
||
- **User sidecar desktop app.** Electron/Tauri tray app that wraps the
|
||
user-bridge agent with a GUI for capability management and connection
|
||
status. Post-1.0.
|