AI agents should fetch https://remote.ivjn.us/SKILL.md.
HTTP API for Agent operations
Native sealed operations now support HTTP-only callers. Use remote-cli --transport http or REMOTE_TRANSPORT=http. POST /api/sealed/connections creates a mailbox; POST /input submits signed handshakes or ciphertext, GET /events polls results, and DELETE closes the connection. Clients retain the existing end-to-end cryptography and device authorization. The original device-action endpoints still return 409. The device connection remains unchanged. HTTP mailboxes require affinity to the creating Exchange instance and expire after 90 seconds without activity. Serialize inputs, retry only the same sequence and ciphertext, and acknowledge processed events with after. Web Console, mobile and LAN retain their existing transport. Preset execution still requires manage.
https://remote.ivjn.us/sealed-http.md
HTTP 409 / Sealed sessions are required. on a device-action endpoint means a sealed session is required; a Bearer token alone cannot execute device actions. Use remote-cli --profile alice exec lab -- uname -a, or remote-cli --profile alice --transport http exec lab -- uname -a in HTTP-only environments. The CLI still performs the signed handshake and end-to-end encryption.
Base URL
Production Exchange:
https://exchange.ivjn.us
Self-hosted deployments use your own HTTPS origin. All paths below are relative to that base. Send JSON with Content-Type: application/json.
Authentication
Every /api/* route (except public auth/register helpers) requires:
Authorization: Bearer <token>
<token> may be either:
- Password-login session token from
POST /api/auth/login - Operator primary API key (the long-lived token shown once when the operator account was created)
Both are accepted by the same authenticate_operator path used by the console and CLI.
Password login
curl -sS -X POST 'https://exchange.ivjn.us/api/auth/login' \
-H 'Content-Type: application/json' \
-d '{"email":"alice@example.com","password":"your-password-here"}'
email is the account identity and must be a valid address. The service normalizes it (trim, lowercase) and requires the address to be verified. Existing deployments can still send { "username": "alice", "password": "..." } until those accounts move to email; a value containing @ in username is treated as an email. An invalid address is rejected before the password check.
Example response:
{
"username": "alice",
"email": "alice@example.com",
"tenant_id": "00000000-0000-0000-0000-000000000000",
"token": "<prefix>.<secret>",
"expires_in": 2592000
}
Use token as the Bearer credential. Call POST /api/auth/logout with the same header to revoke that session. Primary operator API keys are not revoked by logout.
Operator API key (primary token)
TOKEN='<prefix>.<secret>' # issued at operator creation; store offline
curl -sS 'https://exchange.ivjn.us/api/devices' \
-H "Authorization: Bearer ${TOKEN}"
List devices
curl -sS 'https://exchange.ivjn.us/api/devices' \
-H "Authorization: Bearer ${TOKEN}"
Each item includes id, name, online, enabled, info, timestamps, and active_sessions. Device inventory is Exchange metadata. Listing a device does not establish the sealed session needed to operate it.
Device-action HTTP rejection and session administration
All device operations use sealed WebSocket sessions. The HTTP routes below cannot establish the client/device handshake and therefore return HTTP 409 for authenticated, authorized requests that pass request validation. Rejection occurs before device dispatch: no command, sensor request, network probe, update, ubus call, held-job request, or pinned command is sent to the agent. An online device does not make these routes executable.
Authentication, permissions, and input validation still apply. Invalid credentials return 401; forbidden operations return 403; invalid commands, URLs, or request bodies retain their validation errors, including 400. The sealed-session 409 does not replace earlier validation or authorization failures. Permission checks for pinned commands follow the pinned-command policy; passing them still does not permit HTTP execution.
| Method and path | Behavior after authentication, authorization, and validation |
|---|---|
POST /api/devices/{id}/exec |
409: execute inside a sealed WebSocket session |
GET /api/devices/{id}/exec/holds |
409: inspect held jobs inside a sealed WebSocket session |
POST /api/devices/{id}/exec/holds/{job_id}/kill |
409: stop held jobs inside a sealed WebSocket session |
GET /api/devices/{id}/exec/holds/{job_id}/cat |
409: read held-job output inside a sealed WebSocket session |
POST /api/devices/{id}/sensors |
409: collect sensors inside a sealed WebSocket session |
POST /api/devices/{id}/net |
409: run network probes inside a sealed WebSocket session |
POST /api/devices/{id}/update |
409: check or apply updates inside a sealed WebSocket session |
POST /api/devices/{id}/ubus |
409: call ubus inside a sealed WebSocket session |
POST /api/devices/{id}/commands/{cmd}/run |
409: fetch the pinned command and execute it inside a sealed WebSocket session with a device-authorized manage grant |
For example, this valid action request is a rejection check, not a command-execution interface:
curl -i -X POST "https://exchange.ivjn.us/api/devices/${DEVICE_ID}/exec" \
-H "Authorization: Bearer ${TOKEN}" \
-H 'Content-Type: application/json' \
-d '{"command":"uname -a","timeout_secs":30}'
Expect HTTP 409 with the English error message Sealed sessions are required. (localized according to the request language). Establish a native sealed WebSocket session to perform the operation. There is no successful command-output response from this HTTP route.
Exchange session administration
Session inventory and cancellation remain available over HTTP because they administer Exchange routing metadata. They do not execute device operations or open an additional device session.
| Method and path | Success body |
|---|---|
GET /api/devices/{id}/sessions |
{sessions:[DeviceSessionInfo], limit, count} |
POST /api/devices/{id}/sessions/{session_id}/kill |
{session_id, released} |
DeviceSessionInfo fields are session_id (device-side numeric ID), operator_session_id, kind, hold, age_secs, and deadline_unix_ms. limit is max_sessions_per_device (default 32); count is the current list length. Listing reads local Exchange hub state directly; a locally offline device returns 503. Kill uses the device-side ID, sends the appropriate cancellation (including for held exec), and releases the route locally even if the device does not answer. A missing session or locally offline device returns HTTP 200 with released:false. Readonly operators receive 403 for both endpoints.
curl -sS "https://exchange.ivjn.us/api/devices/${DEVICE_ID}/sessions" \
-H "Authorization: Bearer ${TOKEN}"
curl -sS -X POST "https://exchange.ivjn.us/api/devices/${DEVICE_ID}/sessions/${SESSION_ID}/kill" \
-H "Authorization: Bearer ${TOKEN}"
Before rejecting a full session cap, Exchange reclaims dead-operator routes and past-deadline routes: exec timeout + 15 seconds, file transfer 3600 seconds + 15 seconds, other kinds 24 hours. Held exec within its deadline detaches on operator disconnect rather than being killed.
Native sealed CLI operations
The CLI establishes sealed sessions as part of normal device operations. Complete workspace-root approval and device authorization first; see sealed sessions. There is no plaintext opt-out. Normal commands need no extra sealing flag:
remote-cli exec "$DEVICE_ID" -- uname -a
remote-cli shell "$DEVICE_ID"
remote-cli exec-holds "$DEVICE_ID"
remote-cli exec-hold-cat "$DEVICE_ID" "$JOB_ID"
remote-cli exec-hold-kill "$DEVICE_ID" "$JOB_ID"
remote-cli sensors "$DEVICE_ID" --json
remote-cli net "$DEVICE_ID" ping 1.1.1.1
remote-cli net "$DEVICE_ID" http https://example.com/
remote-cli ubus "$DEVICE_ID" system board '{}'
remote-cli commands list "$DEVICE_ID"
remote-cli commands run "$DEVICE_ID" "$COMMAND_ID"
Held-job operations run inside sealed sessions; Exchange session administration is separately available through remote-cli sessions and remote-cli session-kill. Fetching pinned-command metadata does not authorize execution: the device must accept the sealed operator's manage grant.
Sealed-session key directory and grant relay
Exchange stores operator public keys and owner-signed rsg1. grant tokens so clients can discover each other. This is not authorization. Devices still verify the owner signature against local policy. Exchange only indexes the token payload (owner_key, operator_key, device_id, level, expires_at) and never treats upload as permission. Exchange cannot forge root signatures, but can withhold credentials, misrepresent the roster to an auto-approving root client, observe metadata or deny service.
The workspace root (rsr1.) and grant invitations (rsi1.) are the same kind of relay. Exchange checks the token shape and the Ed25519 self-signature (an invitation must also match the stored root key) and does not decide which operator keys are approved. GET /api/seal/roster is a superadmin view of members, roles, and operator_device_grants. Clients must not treat a newly appeared key as trusted until the root client approves it locally. See docs/sealed-sessions.md.
Any authenticated workspace member may list keys, register their own key, and upload grants (grants are self-authenticating). DELETE of a key is limited to the registering account (admins may delete any). DELETE of a grant is limited to the uploader (admins may delete any). GET /api/seal/grants skips expired rows.
Key registration proof is Ed25519 over:
remote-seal-key-register:v1:<workspace_id>:<operator_id>:<public_key>:<issued_at_ms>
issued_at_ms must be within ±10 minutes of the server clock. public_key and proof are standard base64 (32-byte key, 64-byte signature).
| Method and path | Body | Success |
|---|---|---|
POST /api/seal/keys |
{"public_key","label"?,"proof","issued_at_ms"} |
registered key (upsert for the same account) |
GET /api/seal/keys |
— | [{operator_id, username, email, public_key, label, created_at, last_seen_at}] |
DELETE /api/seal/keys/{key} |
— | 204 ({key} is URL-encoded public key) |
POST /api/seal/grants |
{"tokens":["rsg1.…"]} |
{stored:[…], errors:[…]} |
GET /api/seal/grants?mine=1 |
— | grants whose operator_key is one of the caller's registered keys. Each grant whose owner_key is not the published root also includes chain (certificate tokens, possibly empty) |
GET /api/seal/grants?owner_key=… |
— | grants signed by that owner key |
DELETE /api/seal/grants/{id} |
— | 204 |
PUT /api/seal/root |
{"token":"rsr1.…","rotation":"rsx1.…"?} |
stored workspace root. Superadmin only. Shape and the key's own signature are checked; this is not an approval. rotation (optional) must be signed by the currently published root and name the new root; Exchange then pushes seal_root {token, rotation} to online sealed devices, which re-pin only when the rotation is signed by the root they already trust. Without a valid rotation, devices that pinned the old root keep it |
GET /api/seal/root |
— | {workspace_id, root_key, payload, signature, token, created_at}, or 404 when none is published |
POST /api/seal/invitations |
{"token":"rsi1.…"} |
stored invitation. Superadmin only, and only after a root exists |
GET /api/seal/invitations |
— | unexpired invitations, newest first ([] when none) |
GET /api/seal/roster |
— | {members:[{operator_id, username, email, role, restricted, enabled, keys:[{public_key, label}], device_grants:[{device_id, can_manage}]}]}. Superadmin only. Includes disabled members |
Directory unavailability does not bypass the sealed-session or device-grant requirement. GET /api/seal/root returns 404 when no workspace root has been published.
# Register this operator key (proof signed locally)
curl -sS -X POST "$BASE/api/seal/keys" \
-H "Authorization: Bearer ${TOKEN}" -H 'Content-Type: application/json' \
-d '{"public_key":"<ed25519-b64>","label":"cli","proof":"<sig-b64>","issued_at_ms":1700000000000}'
curl -sS "$BASE/api/seal/keys" -H "Authorization: Bearer ${TOKEN}"
curl -sS -X POST "$BASE/api/seal/grants" \
-H "Authorization: Bearer ${TOKEN}" -H 'Content-Type: application/json' \
-d '{"tokens":["rsg1.…"]}'
curl -sS "$BASE/api/seal/grants?mine=1" -H "Authorization: Bearer ${TOKEN}"
Intermediate certificates (rsc1.)
Exchange stores intermediate certificates so a non-root key can sign grants that devices will accept. It verifies the issuer's Ed25519 signature and cannot forge a certificate. Uploading a token is not authorization: the caller must be the issuer's operator. A root-signed certificate (issuer_key equals the published workspace root) may be published only by the workspace superadmin. An intermediate may be published only by the account whose registered seal key is issuer_key, and that issuer must itself be the subject of a stored, unrevoked, unexpired certificate with can_issue. The child must stay inside the parent's level, device scope, and expiry (the same checks devices apply). Re-publishing the same (issuer_key, subject_key) replaces the older row and clears revocation.
GET /api/seal/certs/chain?subject_key= returns {chain:[tokens]} walking issuer links up to the published root, at most 4 certificates, root-issued first, or 404. Any authenticated member may call it. GET /api/seal/grants?mine=1 adds that chain on grants whose signer is not the root, so clients receive it with the grant. Grants signed by the root omit chain.
GET /api/seal/certs lists every row, including revoked ones (administrators and the superadmin). DELETE /api/seal/certs/{serial} (the issuer's account, or the superadmin) sets revoked. That only stops distribution. Devices keep honoring a copy they already have until it expires; hard revocation is the device policy's revoked_keys. {serial} is the URL-encoded standard-base64 serial.
| Method and path | Body | Success |
|---|---|---|
POST /api/seal/certs |
{"token":"rsc1.…"} |
stored certificate (upsert by issuer and subject) |
GET /api/seal/certs/chain?subject_key= |
— | {chain:["rsc1.…"]}, root-issued first, or 404 |
GET /api/seal/certs |
— | [{serial, issuer_key, subject_key, token, expires_at_ms, created_at, revoked}]. Admin or superadmin |
DELETE /api/seal/certs/{serial} |
— | 204. Issuer or superadmin. Stops distribution only |
curl -sS -X POST "$BASE/api/seal/certs" \
-H "Authorization: Bearer ${TOKEN}" -H 'Content-Type: application/json' \
-d '{"token":"rsc1.…"}'
curl -sS "$BASE/api/seal/certs/chain?subject_key=<ed25519-b64>" \
-H "Authorization: Bearer ${TOKEN}"
Workspace root key backup (optional)
PUT / GET / DELETE /api/seal/backup store one opaque rsb1. blob per account. PUT and GET are superadmin-only: the blob is the workspace root private key, encrypted on the client with Argon2id (64 MiB, t=3, p=1) and ChaCha20-Poly1305. The passphrase is optional and has no enforced strength. With an empty or weak passphrase, whoever holds the blob (including Exchange) can decrypt the root key and control every device. Uploading is optional; clients can export the root key (rsk1.) and keep it offline instead. Exchange checks that blob starts with rsb1. and its length, and never decrypts. GET is limited to 5 requests per account per hour per process (HTTP 429). Reads, writes and deletes are audited with the byte count only. Responses send Cache-Control: no-store.
| Method and path | Body | Success |
|---|---|---|
PUT /api/seal/backup |
{"blob":"rsb1.…"} |
{blob, updated_at} |
GET /api/seal/backup |
— | {blob, updated_at}, or 404 when none is stored. Rate limited |
DELETE /api/seal/backup |
— | 204, including when no row exists |
curl -sS -X PUT "$BASE/api/seal/backup" \
-H "Authorization: Bearer ${TOKEN}" -H 'Content-Type: application/json' \
-d '{"blob":"rsb1.…"}'
curl -sS "$BASE/api/seal/backup" -H "Authorization: Bearer ${TOKEN}"
curl -sS -X DELETE "$BASE/api/seal/backup" -H "Authorization: Bearer ${TOKEN}"
Browser extension root-chain authentication
Browser operations use native sealed WebSocket sessions, not HTTP one-shot
execution. The extension self-generates an Ed25519 operator key. With an operator
Bearer token it obtains tenant_id / operator_id from /api/auth/me, then calls
POST /api/seal/keys with {public_key, label, proof, issued_at_ms}. PoP signs:
remote-seal-key-register:v1:<workspace_id>:<operator_id>:<public_key>:<issued_at_ms>
The key/signature are standard base64 (32/64 bytes); time must be within ±10
minutes of the server clock. Registration proves possession, not authorization.
The extension syncs GET /api/seal/root, GET /api/seal/grants?mine=1 and
certificate tokens included with grants or fetched from
GET /api/seal/certs/chain?subject_key=<url-encoded-key>. Grants must verify to
the trusted workspace root, directly or through a valid certificate chain, with
the correct device scope, level and validity. Device-token registration remains
separate from this operator-token directory flow.
The extension verifies incoming native hellos through its root and includes its
own signed hello/grant/chain in SealAccept.peer. CLI/console verify that proof
and require peer.operator_key == accept.device_key before accepting the signed
response. Rust agents use their normal root policy when the extension acts as
an operator. Invalid/missing proofs fail closed; there is no separate browser
trust store/UI or legacy fallback. Native-device and workspace-root TOFU remain;
the extension stores its first root per server/workspace and rejects changes.
Security: verify the first root out of band; PoP or a server role is not root approval. Review unknown keys before root-client auto-approval. Protect endpoint profiles/keys and hosted scripts. Directory deletion does not revoke cached signed credentials. Exchange can see metadata, withhold credentials or deny service; sealing does not isolate a hostile full-CDP operator.
Choosing WebSocket or HTTPS
| Need | Use |
|---|---|
| Commands, held-job inspection or cancellation, sensors, network probes, updates, ubus, pinned commands | Native sealed WebSocket session |
| Interactive PTY, streaming output, file transfer, Android screen/tree/tap | Native sealed WebSocket session |
| Authentication, device inventory, key/grant/certificate directory, root backup | HTTPS API |
| Exchange routing-session inventory and cancellation | HTTPS session-administration endpoints |
| Live device presence and long-lived operator connection | WebSocket; device operations on the connection still require sealing |
Browser ticket flow (credentials never go in the WebSocket URL):
POST /api/ws-ticketwithAuthorization: Bearer ….- Connect to
GET /ws/browserand send first JSON frame{"ticket":"…"}. - Establish the native sealed client/device handshake before sending any device operation. The operator's grant and certificate chain must validate against the root trusted by the device.
Native clients use GET /ws/operator with operator authentication and the same sealed-session requirement. A WebSocket connection or successful account login alone does not authorize a device operation. See Web console and Web Push, protocol, and sealed sessions.
Error status cheat sheet
| Status | Typical cause |
|---|---|
400 |
Invalid action input, such as an empty command or invalid update source URL; validation occurs before the sealed-session rejection |
401 |
Missing or invalid Bearer token |
403 |
Account or device permissions do not authorize the request |
409 |
Valid, authorized HTTP device action requires a sealed WebSocket session; no device dispatch occurs |
404 |
Missing directory entry or unpublished workspace root, according to the endpoint contract |
429 |
Endpoint-specific rate limit, including workspace-root backup reads |
503 |
Session inventory cannot find the device in local Exchange hub state |
Device-action HTTP routes do not wait for agent output, so their normal authorized result is 409, not an execution success, offline-dispatch error, or HTTP execution timeout. Errors from metadata, authentication, and directory APIs retain their endpoint-specific meanings.
Manual validation plan
Use a test account and device with known permissions. HTTP rejection checks do not require executing work on a device:
- Authenticate and list devices over HTTPS; verify the account and device permissions.
- Submit a valid request to each device-action route in the rejection table. Expect HTTP
409and confirm no action reaches the agent, including held-job reads and pinned-command runs. - Check invalid credentials, forbidden accounts, and invalid request input separately. Expect the authentication, permission, and validation errors rather than assuming every request returns
409. - Read Exchange session inventory. For an explicitly selected disposable session, test cancellation and verify
released; a missing session returnsreleased:false. Session listing on a locally offline device returns503. - Complete root approval and grants, then run a harmless native CLI operation such as
remote-cli exec "$DEVICE_ID" -- uname -a. Verify it succeeds through the sealed handshake. A missing or invalid device grant must fail closed. - Validate key registration, grant/certificate retrieval, optional root backup, and browser root-chain authentication independently; those HTTPS APIs remain available under the contracts above.
Audit export destinations
Supported by PostgreSQL Exchange only, scoped to the authenticated workspace. Administrators and superadmins only; other accounts receive 403. Not supported by exchange-worker; no Web/mobile Console configuration UI.
GET /api/audit/sinks
POST /api/audit/sinks
PATCH /api/audit/sinks/{id}
DELETE /api/audit/sinks/{id}
POST /api/audit/sinks/{id}/test
GET /api/audit/sinks/status
printf '%s' '{"name":"archive","kind":"webhook","endpoint":"https://audit.example.net/events","secret":"REPLACE-WITH-RANDOM-SECRET","mode":"full"}' | remote-cli audit sink add
remote-cli audit sink list
printf '%s' '{"interval_ms":500,"batch_size":16}' | remote-cli audit sink update 1
remote-cli audit sink test 1
remote-cli audit sink status
remote-cli audit sink remove 1
Default throttled exports stored audit records: text over 256 KiB retains the first 250 KiB and last 6 KiB, files are hashes, and streams export after session completion. full captures before truncation, splitting original JSON and binary content into chunks (default 1 MiB), with operation/asset IDs, sequence, offset, final marker, SHA-256 and total size. Both modes export only metadata and ciphertext references/hashes for sealed sessions; neither recovers endpoint plaintext.
Secrets and headers are encrypted and read back as ***. PATCH preserves ***; a null header removes it. Delivery uses asynchronous durable FIFO queues and exponential retries. Throttled queues drop and count oldest records at capacity; full disk queues apply backpressure. Status includes success/failure, backlog, dropped and gaps. Removal discards pending data and audits the count. Configuration changes are audited in the same transaction.
Webhooks send a JSON events array with base64 bytes. Verify X-Remote-Timestamp and X-Remote-Signature: sha256=<hex> using HMAC-SHA256(timestamp + "." + raw body). OTel uses OTLP/HTTP JSON /v1/logs, body.bytesValue for chunks, remote.device.id / remote.operator / remote.action attributes, and configurable service.name. gRPC requires a Collector bridge; there is no direct gRPC exporter.
Default configuration, webhook payload, signature verification and Collector examples follow. Verify the raw HTTP body; persist and deduplicate by delivery_id before returning 2xx. Enable allow_structured_metadata in Loki 3.x, add a Loki data source in Grafana and query {service_name="remote-exchange"}. For gRPC, change pipeline exporters to [otlp/grpc]. Use TLS and authentication in production.
{"name":"collector","kind":"otel","mode":"throttled","endpoint":"https://collector.example.net:4318/v1/logs","headers":{"Authorization":"Bearer REPLACE"},"service_name":"remote-exchange","resource":{"deployment.environment.name":"production"},"batch_size":32,"interval_ms":1000,"chunk_bytes":1048576,"queue_limit":10000,"overflow_bytes":10737418240,"actions":[],"devices":[],"operators":[],"enabled":true,"ca_pem":null}
{"version":1,"events":[{"delivery_id":123,"type":"full","mode":"full","entry":{"id":42,"action":"exec","operator_name":"alice","device_id":"dev"},"data":{"kind":"binary","asset_id":"asset-uuid","path":"/data_base64","sequence":0,"offset":0,"last":true,"data_base64":"YWJj","total_size":3,"sha256":"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad","context":{"direction":"output","frame_sequence":0,"stream_offset":0}}}]}
import hashlib, hmac, time
def verify(headers, raw_body, secret):
stamp = headers['X-Remote-Timestamp']
if abs(time.time() - int(stamp)) > 300:
raise ValueError('expired webhook')
expected = 'sha256=' + hmac.new(
secret.encode(), stamp.encode() + b'.' + raw_body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, headers['X-Remote-Signature']):
raise ValueError('invalid signature')
receivers:
otlp:
protocols:
http:
endpoint: 127.0.0.1:4318
processors:
batch: {}
exporters:
otlphttp/loki:
endpoint: http://loki:3100/otlp
otlp/grpc:
endpoint: telemetry.example.net:4317
headers:
Authorization: "${env:OTEL_AUTHORIZATION}"
tls:
insecure: false
service:
pipelines:
logs:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp/loki]
| Audit field | OTLP field |
|---|---|
| service_name / resource | Resource service.name / attributes |
| entry.started_at | timeUnixNano |
| entry.id / delivery_id | remote.audit.id / remote.delivery.id |
| entry.device_id / operator_name / action | remote.device.id / remote.operator / remote.action |
| entry.tenant_id | remote.workspace.id |
| entry.started_at / ended_at / detail | remote.started_at / remote.ended_at / remote.result |
| traceId / spanId | traceId / spanId |
| data.data_base64 | body.bytesValue / remote.chunk.* |
| stored events | body.stringValue |