Files
ironclaw/wit/channel.wit
Henry Park 3004583b2a feat(ownership): centralized ownership model with typed identities, DB-backed pairing, and OwnershipCache (#1898)
* feat(ownership): add OwnerId, Identity, UserRole, can_act_on types

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(ownership): private OwnerId field, ResourceScope serde derives, fix doc comment

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* refactor(tenant): replace SystemScope::db() escape hatch with typed workspace_for_user(), fix stale variable names

- Add SystemScope::workspace_for_user() that wraps Workspace::new_with_db
- Remove SystemScope::db() which exposed the raw Arc<dyn Database>
- Update 3 callers (routine_engine.rs x2, heartbeat.rs x1) to use the new method
- Fix stale comment: "admin context" -> "system context" in SystemScope
- Rename `admin` bindings to `system` in agent_loop.rs for clarity

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(tenant): rename stale admin binding to system_store in heartbeat.rs

* refactor(tenant): TenantScope/TenantCtx carry Identity, add with_identity() constructor and bridge new()

- TenantScope: replace `user_id: String` field with `identity: Identity`; add `with_identity()` preferred constructor; keep `new(user_id, db)` as Member-role bridge; add `identity()` accessor; all internal method bodies use `identity.owner_id.as_str()` in place of `&self.user_id`
- TenantCtx: replace `user_id: String` field with `identity: Identity`; update constructor signature; add `identity()` accessor; `user_id()` delegates to `identity.owner_id.as_str()`; cost/rate methods updated accordingly
- agent_loop: split `tenant_ctx(&str)` into bridge + new `tenant_ctx_with_identity(Identity)` which holds the full body; bridge delegates to avoid duplication

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* feat(db): add V16 tool scope, V17 channel_identities, V18 pairing_requests migrations

- PostgreSQL: V16__tool_scope.sql adds scope column to wasm_tools/dynamic_tools
- PostgreSQL: V17__channel_identities.sql creates channel identity resolution table
- PostgreSQL: V18__pairing_requests.sql creates pairing request table replacing file-based store
- libSQL SCHEMA: adds scope column to wasm_tools/dynamic_tools, channel_identities, pairing_requests tables
- libSQL INCREMENTAL_MIGRATIONS: versions 17-19 for existing databases
- IDEMPOTENT_ADD_COLUMN_MIGRATIONS: handles fresh-install/upgrade dual path for scope columns
- Runner updated to check ALL idempotent columns per version before skipping SQL
- Test: test_ownership_model_tables_created verifies all new tables/columns exist after migrations

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(db): use correct RFC3339 timestamp default in libSQL, document version sequence offset

Replace datetime('now') with strftime('%Y-%m-%dT%H:%M:%fZ', 'now') in the
channel_identities and pairing_requests table definitions (both in SCHEMA and
INCREMENTAL_MIGRATIONS) to match the project-standard RFC 3339 timestamp format
with millisecond precision. Also add a comment clarifying that libSQL incremental
migration version numbers are independent from PostgreSQL VN migration numbers.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* feat(ownership): bootstrap_ownership(), migrate_default_owner, V19 FK migration, replace hardcoded 'default' user IDs

- Add V19__ownership_fk.sql (programmatic-only, not in auto-migration sweep)
- Add `migrate_default_owner` to Database trait + both PgBackend and LibSqlBackend
- Add `get_or_create_user` default method to UserStore trait
- Add `bootstrap_ownership()` to app.rs, called in init_database() after connect_with_handles
- Replace hardcoded "default" owner_id in cli/config.rs, cli/mcp.rs, cli/mod.rs, orchestrator/mod.rs
- Add TODO(ownership) comments in llm/session.rs and tools/mcp/client.rs for deferred constructors

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(ownership): atomic get_or_create_user, transactional migrate_default_owner, V19 FK inline constant, fix remaining 'default' user IDs

- Delete migrations/V19__ownership_fk.sql so refinery no longer auto-applies FK constraints before bootstrap_ownership runs; add OWNERSHIP_FK_SQL constant with TODO for future programmatic application
- Remove racy SELECT+INSERT default in UserStore::get_or_create_user; both PostgreSQL (ON CONFLICT DO NOTHING) and libSQL (INSERT OR IGNORE) now use atomic upserts
- Wrap migrate_default_owner in explicit transactions on both backends for atomicity
- Make bootstrap_ownership failure fatal (propagate error instead of warn-and-continue)
- Fix mcp auth/test --user: change from default_value="default" to Option<String> resolved from configured owner_id
- Replace hardcoded "default" user IDs in channels/wasm/setup.rs with config.owner_id
- Replace "default" sentinel in OrchestratorState test helper with "<unset>" to make the test-only nature explicit

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(ownership): remove default user_id from create_job(), change sentinel strings to <unset>

- Gate ContextManager::create_job() behind #[cfg(test)]; production code must
  use create_job_for_user() with an explicit user_id to prevent DB rows with
  user_id = 'default' being silently created on the production write path.
- Change the placeholder user_id in McpClient::new(), new_with_name(), and
  new_with_config() from "default" to "<unset>" so accidental secrets/settings
  lookups surface immediately rather than silently touching the wrong DB partition.
- Same sentinel change for SessionManager::new() and new_async() in session.rs;
  these are overwritten by attach_store() at startup with the real owner_id.
- Update tests that asserted the old "default" sentinel to expect "<unset>", and
  switch test_list_jobs_tool / test_job_status_tool to create_job_for_user("default")
  to keep ownership alignment with JobContext::default().

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* feat(db): add ChannelPairingStore sub-trait with resolve_channel_identity, upsert/approve pairing, PostgreSQL + libSQL implementations

Adds PairingRequestRecord, ChannelPairingStore trait (5 methods), and
generate_pairing_code() to src/db/mod.rs; implements for PgBackend in
postgres.rs and LibSqlBackend in libsql/pairing.rs; wires ChannelPairingStore
into the Database supertrait bound; all 6 libSQL unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(db): atomic libSQL approve_pairing with BEGIN IMMEDIATE, add case-insensitive/expired/double-approve tests

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* feat(ownership): add OwnershipCache for zero-DB-read identity resolution on warm path

Converts src/ownership.rs to src/ownership/ module directory and adds
src/ownership/cache.rs with a write-through in-process cache mapping
(channel, external_id) -> Identity. Wired as Arc<OwnershipCache> on
AppComponents for Task 8 pairing integration. All 7 cache unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* test(e2e): add ownership model E2E tests and extend pairing tests for DB-backed store

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(e2e): remove unused asyncio import, add fallback assertion in test_pairing_response_structure

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* test(tenant): unit tests for TenantScope::with_identity and AdminScope construction

Adds 5 focused unit tests verifying TenantScope::with_identity stores the
full Identity (owner_id + role), TenantScope::new creates a Member-role
identity, and AdminScope::new returns Some for Admin and None for Member.
Uses LibSqlBackend::new_memory() as the test DB stub.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(ownership): recover from RwLock poison instead of expect() in OwnershipCache

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* test(ownership): integration tests for bootstrap, tenant isolation, and ChannelPairingStore

Adds tests/ownership_integration.rs covering migrate_default_owner idempotency,
TenantScope per-user setting isolation (including Admin role bypass check),
and the full ChannelPairingStore lifecycle (upsert, approve, remove, multi-channel isolation).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(test): remove duplicate pairing tests and flaky random-code assertion from integration suite

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* feat(pairing): rewrite PairingStore to DB-backed async with OwnershipCache

Replaces the file-based pairing store (~/.ironclaw/*-pairing.json,
*-allowFrom.json) with a DB-backed async implementation that delegates
to ChannelPairingStore and writes through to OwnershipCache on reads.

- PairingStore::new(db, cache) uses the DB; new_noop() for test/no-DB
- resolve_identity() cache-first lookup via OwnershipCache
- approve(code, owner_id) removes channel arg (DB looks up by code)
- All WASM host functions updated: pairing_upsert_request uses block_in_place,
  pairing-is-allowed renamed to pairing-resolve-identity returning Option<String>,
  pairing-read-allow-from deprecated (returns empty list)
- Signal channel receives PairingStore via new(config, db) constructor
- Web gateway pairing handlers read from state.store (DB) directly
- extensions.rs derive_activation_status drops PairingStore dependency;
  derives status from extension.active and owner_binding flag instead
- All test call sites updated to use new_noop()

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(pairing): add missing pairing_store field to all GatewayState initializers, fix disk-full post-edit compile

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* feat(channels): remove owner_id from IncomingMessage, user_id is the canonical resolved OwnerId

`owner_id` on `IncomingMessage` was always a duplicate of `user_id` —
both fields held the same value at every call site. Remove the field and
`with_owner_id()` builder, update the four WASM-wrapper and HTTP test
assertions to use `user_id`, and drop the redundant struct literal field
in the routine_engine test helper.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(channels): remove stale owner_id param from make_message test helper

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* test(e2e): add browser/Playwright tests for ownership model — auth screen, chat UI, owner login

Adds five Playwright-based browser tests to the ownership model E2E suite
verifying the web UI experience: authenticated owner sees chat input, unauthenticated
browser sees auth screen, owner can send a message and receive a response, settings
tab renders without errors, and basic page structure is correct after login.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* feat(settings): migrate channel credentials from plaintext settings to encrypted secrets store

Moves nearai.session_token from the plaintext DB settings table to the
AES-256-GCM encrypted secrets store (key: nearai_session_token).

- SessionManager gains an `attach_secrets()` method that wires in the
  secrets store; `save_session` writes to it when available and
  `load_session_from_secrets` is called preferentially over settings
- `migrate_session_credential()` runs idempotently on each startup in
  `init_secrets()`, reading the JSON session from settings, writing it
  to secrets, then deleting the plaintext copy
- Wizard's `persist_session_to_db` now writes to secrets first, falling
  back to plaintext settings only when secrets store is unavailable
- Plaintext settings path is preserved as fallback for installs without
  a secrets store (no master key configured)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(settings): settings fallback only when no secrets store, verify decryption before deleting plaintext

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(ownership): ROLLBACK in libSQL migrate_default_owner, shared OwnershipCache across channels, add dynamic_tools to migration, fix doc comment

- libSQL migrate_default_owner: wrap UPDATE loop in async closure + match to emit ROLLBACK on any mid-transaction failure (mirroring approve_pairing pattern)
- Both backends: add dynamic_tools to the migrate_default_owner table list so agent-built tools are migrated on first pairing
- setup_wasm_channels: accept Arc<OwnershipCache> parameter instead of allocating a fresh cache, share the AppComponents cache
- SignalChannel:🆕 accept Arc<OwnershipCache> parameter and pass it to PairingStore instead of allocating a new cache
- PairingStore: fix module-level and struct-level doc comments to accurately describe lazy cache population after approve()

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(web): use can_act_on for authorization in job/routine handlers instead of raw string comparisons

Replace 12 raw `user_id != user.user_id` / `user_id == user.user_id` string comparisons
in jobs.rs and 4 in routines.rs with calls through the canonical `can_act_on` function
from `crate::ownership`, which is the spec-mandated authorization mechanism.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* chore: include remaining modified files in ownership model branch

* fix: add pairing_store field to test GatewayState initializers, update PairingStore API calls in integration tests

Add missing `pairing_store: None` to all GatewayState struct initializers
in test files. Migrate old file-based PairingStore API calls
(PairingStore::new(), PairingStore::with_base_dir()) to the new DB-backed
API (PairingStore::new_noop()). Rewrite pairing_integration.rs to use
LibSqlBackend with the new async DB-backed PairingStore API.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* chore: cargo fmt

* fix(pairing): truly no-op PairingStore noop mode, ensure owner user in CLI, fix signal safety comments

- PairingStore::upsert_request now returns a dummy record in noop mode instead of
  erroring, and approve silently succeeds (matching the doc promise of "writes
  are silently discarded").
- PairingStore::approve now accepts a channel parameter, matching the updated
  DB trait signature and propagated to all call sites (CLI, web server, tests).
- CLI run_pairing_command ensures the owner user row exists before approval to
  satisfy the FK constraint on channel_identities.owner_id.
- Signal channel block_in_place safety comments corrected from "WASM channel
  callbacks" to "Signal channel message processing".

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(pairing): thread channel through approve_pairing, add created flag, retry on code collision, remove redundant indexes

Addresses PR review comments:
- approve_pairing validates code belongs to the given channel
- PairingRequestRecord.created replaces timing heuristic
- upsert retries on UNIQUE violation (up to 3 attempts)
- redundant indexes removed (UNIQUE creates implicit index)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(ownership): migrate api_tokens, serialize PG approvals, propagate resolved owner_id

Addresses PR review P1/P2 regressions:

- api_tokens included in migrate_default_owner (both backends)
- PostgreSQL approve_pairing uses FOR UPDATE to prevent concurrent approvals
- Signal resolve_sender_identity returns owner_id, set as IncomingMessage.user_id
  with raw phone number preserved as sender_id for reply routing
- Feishu uses resolved owner_id from pairing_resolve_identity in emitted message
- PairingStore noop mode logs warning when pairing admission is impossible

[skip-regression-check]

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(pr-review): sanitize DB errors in pairing handlers, fix doc comments, add TODO for derive_activation_status

- Pairing list/approve handlers no longer leak DB error details to clients
- NotFound errors return user-friendly 'Invalid or expired pairing code' message
- Module doc in pairing/store.rs corrected (remove -> evict, no insert method)
- wit_compat.rs stub comment corrected to match actual Val shape
- TODO added for derive_activation_status has_paired approximation

* fix(pr-review): propagate libSQL query errors in approve_pairing, round-trip validate session credential migration, fix test doc comment

- libSQL approve_pairing: .ok().flatten() replaced with .map_err() to propagate DB errors
- migrate_session_credential: round-trip compares decrypted secret against plaintext before deleting
- ownership_integration.rs: doc comment corrected to match actual test coverage

* fix(pairing): store meta, wrap upserts in transactions, case-insensitive role/channel, log Signal DB errors, use auth role in handlers

- Store meta JSONB/TEXT column in pairing_requests (PG migration V18, libSQL schema + incremental migration 19)
- Wrap upsert_pairing_request in transactions (PG: client.transaction(), libSQL: BEGIN IMMEDIATE/COMMIT/ROLLBACK)
- Case-insensitive role parsing: eq_ignore_ascii_case("admin") in both backends
- Case-insensitive channel matching in approve_pairing: LOWER(channel) = LOWER($2)
- Log DB errors in Signal resolve_sender_identity instead of silently discarding
- Use auth role from UserIdentity in web handlers (jobs.rs, routines.rs) via identity_from_auth helper
- Fix variable shadowing: rename `let channel` to `let req_channel` in libsql approve_pairing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(security): add auth to pairing list, cache eviction on deactivate, runtime assert in Signal, remove default fallback, warn on noop pairing codes

Addresses zmanian's review:
- #1: pairing_list_handler requires AuthenticatedUser
- #2: OwnershipCache.evict_user() evicts all entries for a user on suspension
- #3: debug_assert! for multi-thread runtime in Signal block_in_place
- #9: Noop PairingStore warns when generating unredeemable codes
- #10: cli/mcp.rs default fallback replaced with <unset>

* fix(pairing): consistent LOWER() channel matching in resolve_channel_identity, fix wizard doc comment, fix E2E test assertion for ActionResponse convention

* fix(pairing): apply LOWER() consistently across all ChannelPairingStore queries (upsert, list_pending, remove)

All channel matching now uses LOWER() in both PostgreSQL and libSQL backends:
- upsert_pairing_request: WHERE LOWER(channel) = LOWER($1)
- list_pending_pairings: WHERE LOWER(channel) = LOWER($1)
- remove_channel_identity: WHERE LOWER(channel) = LOWER($1)

Previously only resolve_channel_identity and approve_pairing used LOWER(),
causing inconsistent matching when channel names differed by case.

* fix(pairing): unify code challenge flow and harden web pairing

* test: harden pairing review follow-ups

* fix: guard wasm pairing callbacks by runtime flavor

* fix(pairing): normalize channel keys and serialize pg upserts

* chore(web): clean up ownership review follow-ups

* Preserve WASM pairing allowlist compatibility

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:51:09 -07:00

431 lines
18 KiB
Plaintext

package near:agent@0.3.0;
// WASM Channel Sandbox Interface
//
// Defines the contract between sandboxed channels and the host runtime.
// Channels export the `channel` interface; the host provides the `channel-host` interface.
//
// Architecture: Host-Managed Event Loop
// ┌─────────────────────────────────────────────────────────────────────────────────┐
// │ Host-Managed Event Loop │
// │ │
// │ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │
// │ │ HTTP │ │ Polling │ │ Timer │ │
// │ │ Router │ │ Scheduler │ │ Scheduler │ │
// │ └──────┬──────┘ └──────┬───────┘ └──────┬───────┘ │
// │ └───────────────────┴────────────────────┘ │
// │ │ │
// │ ▼ │
// │ ┌──────────────────┬──────────────────┐ │
// │ ▼ ▼ ▼ │
// │ ┌───────────┐ ┌────────┐ ┌──────────┐ ┌──────────┐ │
// │ │on-http-req│ │on-poll │ │on-respond│ │on-status │ WASM Exports │
// │ └───────────┘ └────────┘ └──────────┘ └──────────┘ │
// │ │ │ │ │
// │ └──────────────────┴──────────────────┘ │
// │ │ │
// │ ▼ │
// │ ┌─────────────────┐ │
// │ │ Host Imports │ │
// │ │ emit-message │──────────▶ MessageStream │
// │ │ http-request │ │
// │ └─────────────────┘ │
// └─────────────────────────────────────────────────────────────────────────────────┘
//
// Security Model:
// - WASM channels are untrusted and run in a sandbox
// - Fresh instance per callback (no shared mutable state)
// - All capabilities are opt-in (default: no access)
// - Secrets are NEVER exposed to WASM; credentials are injected at host boundary
// - Workspace writes are prefixed with channels/<name>/ to prevent escape
// - Message emission is rate-limited
/// Host-provided capabilities for sandboxed channels.
///
/// Extends base tool capabilities with channel-specific functions:
/// - emit-message: Queue messages for delivery to the agent
/// - workspace-write: Write to channel-namespaced workspace
interface channel-host {
// ==================== Base Capabilities (from tool host) ====================
/// Log levels for structured logging.
enum log-level {
trace,
debug,
info,
warn,
error,
}
/// Emit a log message.
///
/// Messages are collected and emitted after execution completes.
/// Rate-limited to 1000 entries per execution, 4KB per message.
log: func(level: log-level, message: string);
/// Get the current timestamp in milliseconds since Unix epoch.
now-millis: func() -> u64;
/// Read a file from the workspace.
///
/// Path is automatically prefixed with channels/<name>/.
/// Path must be relative (no leading /) and cannot contain "..".
/// Returns None if the file doesn't exist.
workspace-read: func(path: string) -> option<string>;
/// Response from an HTTP request.
record http-response {
/// HTTP status code.
status: u16,
/// Response headers as JSON object string.
headers-json: string,
/// Response body bytes.
body: list<u8>,
}
/// Make an HTTP request (if capability granted).
///
/// Security:
/// - Only allowed endpoints (host/path patterns) can be accessed
/// - Credentials are injected by the host; WASM never sees them
/// - Response is scanned for leaked secrets before returning
/// - Rate-limited per channel
///
/// The optional timeout-ms parameter controls the HTTP client timeout
/// in milliseconds. Defaults to 30000 (30s) when not provided. Use a
/// longer timeout for long-polling requests (e.g., Telegram getUpdates).
/// Capped at the channel's callback_timeout to prevent hangs.
http-request: func(
method: string,
url: string,
headers-json: string,
body: option<list<u8>>,
timeout-ms: option<u32>,
) -> result<http-response, string>;
/// Check if a secret exists (if capability granted).
///
/// Security:
/// - WASM can only check existence, NEVER read values
/// - Only allowed secret names can be checked
/// - Actual credentials are injected by host during HTTP requests
secret-exists: func(name: string) -> bool;
// ==================== Channel-Specific Capabilities ====================
/// A file or media attachment on an inbound message (channel → agent).
///
/// Core fields are part of the record. Extended metadata (duration, dimensions,
/// codec, etc.) goes in `extras-json` to avoid WIT record changes when new
/// properties are needed. Binary data (e.g., downloaded voice bytes) should be
/// stored via `store-attachment-data` rather than inlined in the record.
record inbound-attachment {
/// Unique identifier within the channel (e.g., Telegram file_id).
id: string,
/// MIME type (e.g., "image/jpeg", "audio/ogg", "application/pdf").
mime-type: string,
/// Original filename, if known.
filename: option<string>,
/// File size in bytes, if known.
size-bytes: option<u64>,
/// URL to download the file from the channel's API.
/// May require authentication (handled by host credential injection).
source-url: option<string>,
/// Opaque key for host-side storage (e.g., after download/caching).
storage-key: option<string>,
/// Extracted text content (e.g., OCR result, PDF text, audio transcript).
extracted-text: option<string>,
/// Extensible metadata as JSON string.
///
/// Used for properties that may be added over time without changing WIT.
/// Well-known keys:
/// - "duration_secs": u32 — duration in seconds (audio/video)
/// - "width": u32, "height": u32 — pixel dimensions (images/video)
/// - "codec": string — audio/video codec
/// - "thumbnail_file_id": string — thumbnail identifier
extras-json: string,
}
/// Store binary data for an attachment (e.g., downloaded voice note bytes).
///
/// Call this before emit-message to associate raw bytes with an attachment.
/// The host retrieves the data after the callback using the attachment ID.
///
/// Security:
/// - Maximum 20MB per attachment
/// - Maximum 50MB total per callback execution
/// - Data is cleared after the callback completes
store-attachment-data: func(attachment-id: string, data: list<u8>) -> result<_, string>;
/// A message to emit to the agent.
record emitted-message {
/// User identifier within the channel (e.g., Slack user ID).
user-id: string,
/// Optional human-readable user name.
user-name: option<string>,
/// Message content.
content: string,
/// Optional thread ID for threaded conversations.
thread-id: option<string>,
/// Channel-specific metadata as JSON string.
metadata-json: string,
/// File or media attachments on this message.
attachments: list<inbound-attachment>,
}
/// Emit a message to the agent.
///
/// Messages are queued during callback execution and delivered after
/// the callback completes successfully.
///
/// Security:
/// - Rate-limited per execution (max 100 messages)
/// - Rate-limited globally per channel (configurable)
/// - Content size limited to 64KB
emit-message: func(msg: emitted-message);
/// Write a file to the workspace.
///
/// Path is automatically prefixed with channels/<name>/.
/// Path must be relative (no leading /) and cannot contain "..".
///
/// Returns Err if:
/// - Path validation fails (traversal attempt, absolute path)
/// - Write operation fails
workspace-write: func(path: string, content: string) -> result<_, string>;
// ==================== DM Pairing ====================
/// Result of upserting a pairing request.
record pairing-upsert-result {
code: string,
created: bool,
}
/// Upsert a pairing request for an unknown sender.
/// Returns (code, created). When created is true, the channel should send a pairing reply.
pairing-upsert-request: func(
channel: string,
id: string,
meta-json: string
) -> result<pairing-upsert-result, string>;
/// Resolve the owner-id for a paired sender.
/// Returns the owner-id string if the sender is paired, or none if unknown.
pairing-resolve-identity: func(
channel: string,
external-id: string,
) -> result<option<string>, string>;
/// Read paired external IDs for compatibility with legacy allowFrom-based
/// admission checks. New channels should prefer pairing-resolve-identity.
pairing-read-allow-from: func(channel: string) -> result<list<string>, string>;
}
/// Channel interface that sandboxed channels must implement.
interface channel {
// ==================== Configuration Types ====================
/// Configuration for an HTTP endpoint.
record http-endpoint-config {
/// Path to register (e.g., "/webhook/slack").
path: string,
/// Allowed HTTP methods (e.g., ["POST"]).
methods: list<string>,
/// Whether the endpoint requires secret validation.
require-secret: bool,
}
/// Configuration for polling behavior.
record poll-config {
/// Polling interval in milliseconds (minimum 30000).
interval-ms: u32,
/// Whether polling is enabled.
enabled: bool,
}
/// Channel configuration returned by on-start.
record channel-config {
/// Human-readable display name.
display-name: string,
/// HTTP endpoints to register.
http-endpoints: list<http-endpoint-config>,
/// Optional polling configuration.
poll: option<poll-config>,
}
// ==================== Request/Response Types ====================
/// Incoming HTTP request from a webhook.
record incoming-http-request {
/// HTTP method (GET, POST, etc.).
method: string,
/// Request path.
path: string,
/// Request headers as JSON object string.
headers-json: string,
/// Query parameters as JSON object string.
query-json: string,
/// Request body bytes.
body: list<u8>,
/// Whether the webhook secret was validated by the host.
secret-validated: bool,
}
/// HTTP response to return to the webhook caller.
record outgoing-http-response {
/// HTTP status code.
status: u16,
/// Response headers as JSON object string.
headers-json: string,
/// Response body bytes.
body: list<u8>,
}
/// A file or image attachment on an outbound message (agent → channel).
///
/// Contains raw file bytes for the channel to upload/send.
record attachment {
/// Original filename (e.g., "screenshot.png").
filename: string,
/// MIME type (e.g., "image/png").
mime-type: string,
/// Raw file bytes.
data: list<u8>,
}
/// Agent response to be sent back to the channel.
record agent-response {
/// Unique message ID for correlation.
message-id: string,
/// Response content from the agent.
content: string,
/// Optional thread ID for threaded replies.
thread-id: option<string>,
/// Channel-specific metadata as JSON string.
metadata-json: string,
/// File/image attachments to send.
attachments: list<attachment>,
}
// ==================== Status Types ====================
/// Types of status updates the agent can send to channels.
enum status-type {
/// Agent is thinking/processing a response.
thinking,
/// Agent finished processing (response sent or about to be sent).
done,
/// Agent processing was interrupted.
interrupted,
/// A tool execution started.
tool-started,
/// A tool execution completed.
tool-completed,
/// A tool execution produced a preview/result status.
tool-result,
/// A tool call is waiting for user approval.
approval-needed,
/// Generic status text that should be shown to the user.
status,
/// A background/sandbox job was started.
job-started,
/// An extension/tool requires user authentication.
auth-required,
/// Authentication flow completed.
auth-completed,
}
/// A status update from the agent.
record status-update {
/// The type of status change.
status: status-type,
/// Human-readable description of the status.
message: string,
/// Channel-specific metadata as JSON string (e.g., contains chat_id for routing).
metadata-json: string,
}
// ==================== Lifecycle Callbacks ====================
/// Initialize the channel.
///
/// Called once when the channel is loaded. Returns configuration
/// describing HTTP endpoints and polling behavior.
///
/// Arguments:
/// - config-json: Channel configuration from the capabilities file.
///
/// Returns:
/// - Ok(channel-config): Configuration for the host to set up routing
/// - Err(string): Initialization failure message
on-start: func(config-json: string) -> result<channel-config, string>;
/// Handle an incoming HTTP request.
///
/// Called for each HTTP request to a registered endpoint.
/// Use emit-message to queue messages for the agent.
///
/// Arguments:
/// - req: The incoming HTTP request
///
/// Returns:
/// - HTTP response to send back to the caller
on-http-request: func(req: incoming-http-request) -> outgoing-http-response;
/// Handle a polling tick.
///
/// Called periodically if polling is configured.
/// Use emit-message to queue messages discovered during polling.
on-poll: func();
/// Deliver an agent response to the channel.
///
/// Called when the agent has generated a response to a message
/// that was emitted by this channel.
///
/// Arguments:
/// - response: The agent's response
///
/// Returns:
/// - Ok: Response delivered successfully
/// - Err(string): Delivery failure message
on-respond: func(response: agent-response) -> result<_, string>;
/// Notify the channel of agent status changes.
///
/// Called when the agent starts thinking, finishes, or changes state.
/// Channels can use this to show typing indicators or status messages.
///
/// Arguments:
/// - update: The status update
on-status: func(update: status-update);
/// Send a proactive message to a user without a prior incoming message.
///
/// Used for broadcasts, alerts, and agent-initiated messages with attachments.
/// The user-id identifies the target user within the channel.
///
/// Arguments:
/// - user-id: Target user identifier (e.g., Telegram chat_id)
/// - response: The message content and attachments to send
///
/// Returns:
/// - Ok: Message delivered successfully
/// - Err(string): Delivery failure message
on-broadcast: func(user-id: string, response: agent-response) -> result<_, string>;
/// Clean up channel resources.
///
/// Called when the channel is being unloaded.
on-shutdown: func();
}
/// World definition for sandboxed channels.
///
/// Channels import host capabilities and export the channel interface.
world sandboxed-channel {
import channel-host;
export channel;
}