Skip to content
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,56 @@ and then you can start as many instances you want, each of them are isolated:
vix
```

### Remote control

`vixd` can optionally accept prompts from Telegram or WhatsApp and reply with the
agent's answer. Remote control is disabled by default and requires both a feature
flag and an allowlist in `~/.vix/settings.json`:

```json
{
"features": { "remote_control": true },
"remote_control": {
"enabled": true,
"cwd": "/absolute/project/path",
"workflow": "",
"max_concurrent_runs": 1,
"telegram": {
"enabled": true,
"bot_token": "<telegram bot token>",
"allowed_chat_ids": ["123456789"]
},
"whatsapp": {
"enabled": true,
"access_token": "<whatsapp cloud api token>",
"app_secret": "<meta app secret>",
"phone_number_id": "<phone number id>",
"verify_token": "<webhook verify token>",
"graph_api_version": "v20.0",
"webhook_addr": "127.0.0.1:1340",
"allowed_contacts": ["15551234567"]
}
}
}
```

Telegram uses bot long-polling. WhatsApp exposes `GET/POST /whatsapp` on
`webhook_addr` for Cloud API webhook verification and messages. Only allowlisted
chat IDs/contacts can control vix. Leave `workflow` empty for a plain prompt, or
set it to a workflow name to run that workflow for each remote prompt.

Remote control is not a sandbox guarantee. A remote prompt uses the same
agent/tooling path as vix, subject to unattended confirmation denial and the
configured policy: the configured `cwd`, `$HOME`, and platform system paths may
be auto-allowed, and output is sent back through the chat provider. Treat a
compromised allowlisted account, or provider credentials capable of injecting
trusted inbound messages, as host access within that policy. Treat bot/provider
tokens as sensitive because they can expose traffic, disrupt service, or combine
with sender compromise. Use a locked-down `cwd`, keep `max_concurrent_runs` low,
and add deny-list entries for sensitive paths such as `~/.ssh`, `~/.aws`,
`~/.kube`, password-manager data, and cloud credential directories. Remote runs
deny confirmation prompts and cannot answer user questions or approve plans.

<div align="center">

## Why is vix faster and cheaper in plan mode?
Expand Down
12 changes: 12 additions & 0 deletions cmd/vixd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,18 @@ func main() {
server.RegisterHandler(cmd, handler)
}, cred, ctx)
daemon.RegisterToolHandlers(server)
if config.RemoteControlEnabled() {
remoteCfg, err := daemon.LoadRemoteControlConfig()
if err != nil {
log.Printf("remote control: config load failed: %v", err)
} else if remoteCfg.Enabled {
if err := server.StartRemoteControl(ctx, remoteCfg); err != nil {
log.Printf("remote control: disabled: %v", err)
}
}
} else {
log.Printf("remote control: disabled (features.remote_control=false or VIX_DISABLE_REMOTE_CONTROL)")
}
if *webPort > 0 && !*noMissionControl {
go daemon.StartWebServer(ctx, server, *webPort)
}
Expand Down
26 changes: 26 additions & 0 deletions e2e/scenarios/remote_control_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package scenarios

import (
"testing"

"github.com/get-vix/vix/e2e/harness"
)

// TestRemoteControlUnattendedPolicyAcceptance is a staged acceptance spec for
// remote-control daemon sessions. The current harness can drive TUI sessions and
// CLI-triggered jobs/hooks, but it has no provider ingress/reply primitive for
// Telegram or WhatsApp.
//
// When enabled it should inject a trusted remote message and assert that the
// resulting vix-initiated session denies tool confirmation requests, returns an
// error for user questions and plan proposals, and replies to the provider with
// the final text or remote-control error.
func TestRemoteControlUnattendedPolicyAcceptance(t *testing.T) {
meta := harness.Meta{
Category: "remote_control",
Subcategory: "remote_control.unattended_policy",
Description: "remote-control runs deny confirmations and fail closed for interactive question/plan events",
Wire: harness.WireMessages,
}
harness.SkipScenario(t, meta, "remote-control provider ingress/reply injection is not exposed by the current e2e harness; daemon policy is covered by internal/daemon unit tests")
}
10 changes: 10 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,16 @@ func HooksEnabled() bool {
return feature("hooks", true)
}

// RemoteControlEnabled reads the remote_control feature flag. Defaults to false
// because chat-service control requires explicit credentials and sender allowlists.
// VIX_DISABLE_REMOTE_CONTROL is an emergency kill switch.
func RemoteControlEnabled() bool {
if v := os.Getenv("VIX_DISABLE_REMOTE_CONTROL"); v == "1" || v == "true" {
return false
}
return feature("remote_control", false)
}

// JobsMaxConcurrentRuns reads jobs.max_concurrent_runs from
// ~/.vix/settings.json. Returns 0 when absent/invalid, letting the scheduler
// apply its default.
Expand Down
24 changes: 23 additions & 1 deletion internal/config/defaults/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,29 @@
"read_claude_md": true,
"show_thinking": false,
"telemetry": true,
"tool_orchestrator": false
"tool_orchestrator": false,
"remote_control": false
},
"remote_control": {
"enabled": false,
"cwd": "",
"workflow": "",
"max_concurrent_runs": 1,
"telegram": {
"enabled": false,
"bot_token": "",
"allowed_chat_ids": []
},
"whatsapp": {
"enabled": false,
"access_token": "",
"app_secret": "",
"phone_number_id": "",
"verify_token": "",
"graph_api_version": "v20.0",
"webhook_addr": "127.0.0.1:1340",
"allowed_contacts": []
}
},
"elevenlabs": {
"agent_id": "agent_7501kqrztj1te17ssqz5wqpnvkf3",
Expand Down
4 changes: 2 additions & 2 deletions internal/daemon/hook_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,12 +267,12 @@ consume:
case ev := <-session.eventChan:
switch ev.Type {
case "event.stream_chunk":
finalText.WriteString(decodeJobEvent[protocol.EventStreamChunk](ev.Data).Text)
finalText.WriteString(decodeUnattendedEvent[protocol.EventStreamChunk](ev.Data).Text)
case "event.confirm_request":
data, _ := json.Marshal(protocol.SessionConfirmData{Approved: false})
session.pushCommand(ctx, protocol.SessionCommand{Type: "session.confirm", Data: data})
case "event.user_question":
uq := decodeJobEvent[protocol.EventUserQuestion](ev.Data)
uq := decodeUnattendedEvent[protocol.EventUserQuestion](ev.Data)
answer := ""
if len(uq.RichOptions) > 0 {
answer = uq.RichOptions[0].Title
Expand Down
Loading