aegis-arun 0.1.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,364 @@
1
+ param(
2
+ [Parameter(Mandatory = $true)]
3
+ [ValidateSet("init", "start", "stop", "status", "create-instance", "webhook", "qr", "self-account", "pair", "daemon")]
4
+ [string]$Action,
5
+ [string]$Workspace,
6
+ [string]$AegisBinary,
7
+ [string]$RelayBinary
8
+ )
9
+
10
+ $ErrorActionPreference = "Stop"
11
+ Set-StrictMode -Version Latest
12
+ if ($env:OS -ne "Windows_NT") { throw "This controller requires Windows and Docker Desktop." }
13
+ $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
14
+ if (-not $Workspace) { $Workspace = $repoRoot }
15
+ $Workspace = (Resolve-Path -LiteralPath $Workspace).Path
16
+ $stateDirectory = Join-Path $Workspace ".arun\local-remote"
17
+ $stateFile = Join-Path $stateDirectory "state.json"
18
+ $composeFile = Join-Path $PSScriptRoot "compose.yaml"
19
+ $envFile = Join-Path $stateDirectory "compose.env"
20
+ $nativeRelay = if ($RelayBinary) { (Resolve-Path -LiteralPath $RelayBinary).Path }
21
+ else {
22
+ $bundledRelay = Join-Path $repoRoot "vendor\x86_64-pc-windows-msvc\aegis-relay.exe"
23
+ if (Test-Path -LiteralPath $bundledRelay -PathType Leaf) { (Resolve-Path -LiteralPath $bundledRelay).Path }
24
+ else { Join-Path $repoRoot "relay\target\debug\aegis-relay.exe" }
25
+ }
26
+
27
+ function Protect-Directory([string]$Path) {
28
+ if (Test-Path -LiteralPath $Path) {
29
+ if ((Get-Item -LiteralPath $Path).Attributes -band [IO.FileAttributes]::ReparsePoint) {
30
+ throw "Refusing a linked private directory: $Path"
31
+ }
32
+ }
33
+ else { New-Item -ItemType Directory -Path $Path | Out-Null }
34
+ $acl = Get-Acl -LiteralPath $Path
35
+ $owner = [Security.Principal.WindowsIdentity]::GetCurrent().User
36
+ $allowedSids = @($owner.Value, "S-1-5-18")
37
+ $rules = @($acl.Access)
38
+ $correct = $acl.AreAccessRulesProtected -and $rules.Count -eq 2
39
+ foreach ($rule in $rules) {
40
+ if ($rule.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value -notin $allowedSids -or
41
+ $rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or
42
+ $rule.FileSystemRights -ne [Security.AccessControl.FileSystemRights]::FullControl -or
43
+ $rule.InheritanceFlags -ne [Security.AccessControl.InheritanceFlags]"ContainerInherit,ObjectInherit") { $correct = $false }
44
+ }
45
+ if ($correct) { return }
46
+ $acl.SetAccessRuleProtection($true, $false)
47
+ foreach ($existing in @($acl.Access)) { $acl.RemoveAccessRuleSpecific($existing) }
48
+ $inherit = [Security.AccessControl.InheritanceFlags]"ContainerInherit,ObjectInherit"
49
+ $propagate = [Security.AccessControl.PropagationFlags]::None
50
+ $allow = [Security.AccessControl.AccessControlType]::Allow
51
+ foreach ($sid in @($owner, [Security.Principal.SecurityIdentifier]::new("S-1-5-18"))) {
52
+ $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($sid, "FullControl", $inherit, $propagate, $allow))
53
+ }
54
+ # Set only the modified DACL; Set-Acl can also request audit privileges on Windows.
55
+ [IO.Directory]::SetAccessControl($Path, $acl)
56
+ }
57
+
58
+ function Write-PrivateFile([string]$Path, [string]$Content) {
59
+ if ((Test-Path -LiteralPath $Path) -and
60
+ ((Get-Item -LiteralPath $Path).Attributes -band [IO.FileAttributes]::ReparsePoint)) {
61
+ throw "Refusing a linked private file: $Path"
62
+ }
63
+ [IO.File]::WriteAllText($Path, $Content, [Text.UTF8Encoding]::new($false))
64
+ }
65
+
66
+ function New-Secret {
67
+ $bytes = [byte[]]::new(32)
68
+ $rng = [Security.Cryptography.RandomNumberGenerator]::Create()
69
+ try { $rng.GetBytes($bytes) } finally { $rng.Dispose() }
70
+ return ([BitConverter]::ToString($bytes)).Replace("-", "").ToLowerInvariant()
71
+ }
72
+
73
+ function Get-FreePort {
74
+ $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
75
+ try { $listener.Start(); return ([Net.IPEndPoint]$listener.LocalEndpoint).Port }
76
+ finally { $listener.Stop() }
77
+ }
78
+
79
+ function Get-InstalledAegis {
80
+ if ($AegisBinary) { return (Resolve-Path -LiteralPath $AegisBinary).Path }
81
+ if ($env:ARUN_BINARY) { return (Resolve-Path -LiteralPath $env:ARUN_BINARY).Path }
82
+ $launcher = (Get-Command aegis.cmd -ErrorAction Stop).Source
83
+ $vendor = Join-Path (Split-Path $launcher -Parent) "node_modules\aegis-arun\vendor\x86_64-pc-windows-msvc\arun.exe"
84
+ if (-not (Test-Path -LiteralPath $vendor -PathType Leaf)) {
85
+ throw "Pass -AegisBinary with the installed native Aegis executable."
86
+ }
87
+ return (Resolve-Path -LiteralPath $vendor).Path
88
+ }
89
+
90
+ function Invoke-WithEnvironment([hashtable]$Values, [scriptblock]$Body) {
91
+ $previous = @{}
92
+ foreach ($name in $Values.Keys) {
93
+ $previous[$name] = [Environment]::GetEnvironmentVariable($name, "Process")
94
+ [Environment]::SetEnvironmentVariable($name, [string]$Values[$name], "Process")
95
+ }
96
+ try { & $Body }
97
+ finally {
98
+ foreach ($name in $Values.Keys) { [Environment]::SetEnvironmentVariable($name, $previous[$name], "Process") }
99
+ }
100
+ }
101
+
102
+ function Get-Environment {
103
+ $device = "aegis-$($state.installation_id)"
104
+ $selfOwner = if ($state.PSObject.Properties.Name -contains "self_account_owner") { [string]$state.self_account_owner } else { "" }
105
+ return @{
106
+ LOCAL_REMOTE_DIR = $stateDirectory.Replace("\", "/")
107
+ RELAY_DATABASE_PORT = [string]$state.ports.database
108
+ RELAY_DATABASE_PASSWORD = $state.secrets.relay_database
109
+ EVOLUTION_DATABASE_PASSWORD = $state.secrets.evolution_database
110
+ NATS_PORT = [string]$state.ports.nats
111
+ EVOLUTION_PORT = [string]$state.ports.evolution
112
+ EVOLUTION_API_KEY = $state.secrets.evolution_api
113
+ NATS_AUTH_TOKEN = $state.secrets.nats_relay
114
+ AEGIS_NATS_DEVICE_USERNAME = $device
115
+ AEGIS_NATS_DEVICE_PASSWORD = $state.secrets.nats_device
116
+ AEGIS_NATS_DEVICE_EVENT_SUBJECT = "aegis.events.$($state.installation_id)"
117
+ AEGIS_NATS_DEVICE_CONSUMER_INFO = ('"$JS.API.CONSUMER.INFO.AEGIS_COMMANDS.{0}"' -f $device)
118
+ AEGIS_NATS_DEVICE_CONSUMER_CREATE = ('"$JS.API.CONSUMER.CREATE.AEGIS_COMMANDS.{0}.aegis.commands.{1}"' -f $device, $state.installation_id)
119
+ AEGIS_NATS_DEVICE_CONSUMER_NEXT = ('"$JS.API.CONSUMER.MSG.NEXT.AEGIS_COMMANDS.{0}"' -f $device)
120
+ AEGIS_NATS_DEVICE_ACK = ('"$JS.ACK.AEGIS_COMMANDS.{0}.*.*.*.*.*"' -f $device)
121
+ AEGIS_NATS_DEVICE_INBOX = "_INBOX.aegis.device.$($state.installation_id).>"
122
+ DATABASE_URL = "postgres://relay:$($state.secrets.relay_database)@127.0.0.1:$($state.ports.database)/aegis_relay?sslmode=disable"
123
+ RELAY_ALLOW_INSECURE_LOCAL_DATABASE = "true"
124
+ RELAY_BIND_ADDR = "127.0.0.1:$($state.ports.relay)"
125
+ NATS_URL = "tls://localhost:$($state.ports.nats)"
126
+ NATS_TLS_ROOT_CERT = (Join-Path $stateDirectory "nats-certs\nats.crt")
127
+ RELAY_ADMIN_TOKEN = $state.secrets.relay_admin
128
+ EVOLUTION_BASE_URL = "http://127.0.0.1:$($state.ports.evolution)/"
129
+ EVOLUTION_INSTANCE = $state.instance
130
+ EVOLUTION_WEBHOOK_SECRET = $state.secrets.webhook
131
+ WHATSAPP_SELF_ACCOUNT = $(if ($selfOwner) { "true" } else { "false" })
132
+ WHATSAPP_SELF_ACCOUNT_PENDING = $(if ($selfOwner) { "false" } else { "true" })
133
+ WHATSAPP_SELF_OWNER_PHONE = $selfOwner
134
+ AEGIS_LOCAL_RELAY_ADMIN_TOKEN = $state.secrets.relay_admin
135
+ AEGIS_LOCAL_NATS_TOKEN = $state.secrets.nats_device
136
+ RUST_LOG = "info"
137
+ }
138
+ }
139
+
140
+ function Invoke-Compose([string[]]$Arguments) {
141
+ Invoke-WithEnvironment (Get-Environment) {
142
+ & docker compose --project-name $state.project --env-file $envFile -f $composeFile @Arguments
143
+ if ($LASTEXITCODE -ne 0) { throw "Local Docker Compose command failed ($LASTEXITCODE)." }
144
+ }
145
+ }
146
+
147
+ function Invoke-Evolution([string]$Method, [string]$Route, $Body = $null) {
148
+ $request = @{ Method = $Method; Uri = "http://127.0.0.1:$($state.ports.evolution)/$Route";
149
+ Headers = @{ apikey = $state.secrets.evolution_api }; TimeoutSec = 20 }
150
+ if ($null -ne $Body) { $request.ContentType = "application/json"; $request.Body = ($Body | ConvertTo-Json -Depth 8 -Compress) }
151
+ Invoke-RestMethod @request
152
+ }
153
+
154
+ function Get-LocalInstance {
155
+ foreach ($instance in @(Invoke-Evolution "GET" "instance/fetchInstances")) {
156
+ foreach ($field in @("name", "instanceName")) {
157
+ $property = $instance.PSObject.Properties[$field]
158
+ if ($property -and $property.Value -eq $state.instance) { return $instance }
159
+ }
160
+ }
161
+ return $null
162
+ }
163
+
164
+ function Get-OwnedProcess([string]$Name) {
165
+ $recordPath = Join-Path $stateDirectory "$Name.process.json"
166
+ if (-not (Test-Path -LiteralPath $recordPath -PathType Leaf)) { return $null }
167
+ $record = Get-Content -LiteralPath $recordPath -Raw | ConvertFrom-Json
168
+ $process = Get-Process -Id $record.process_id -ErrorAction SilentlyContinue
169
+ if ($process -and $process.Path -ceq $record.executable -and
170
+ $process.StartTime.ToUniversalTime().Ticks.ToString() -ceq $record.started_ticks) { return $process }
171
+ return $null
172
+ }
173
+
174
+ function Start-OwnedProcess([string]$Name, [string]$Executable, [string[]]$Arguments) {
175
+ if (Get-OwnedProcess $Name) { Write-Host "$Name already running."; return }
176
+ $process = Invoke-WithEnvironment (Get-Environment) {
177
+ $options = @{ FilePath = $Executable; WorkingDirectory = $Workspace; WindowStyle = "Hidden"; PassThru = $true;
178
+ RedirectStandardOutput = (Join-Path $stateDirectory "$Name.stdout.log");
179
+ RedirectStandardError = (Join-Path $stateDirectory "$Name.stderr.log") }
180
+ if ($Arguments.Count -gt 0) { $options.ArgumentList = $Arguments }
181
+ Start-Process @options
182
+ }
183
+ Write-PrivateFile (Join-Path $stateDirectory "$Name.process.json") (@{
184
+ process_id = $process.Id; executable = $Executable; started_ticks = $process.StartTime.ToUniversalTime().Ticks.ToString()
185
+ } | ConvertTo-Json)
186
+ }
187
+
188
+ function Wait-Relay {
189
+ $deadline = [DateTime]::UtcNow.AddSeconds(20)
190
+ while ([DateTime]::UtcNow -lt $deadline) {
191
+ if (-not (Get-OwnedProcess "relay")) { throw "Relay stopped; inspect the private relay.stderr.log." }
192
+ try {
193
+ $response = Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:$($state.ports.relay)/healthz" -TimeoutSec 2
194
+ if ($response.StatusCode -eq 200 -and $response.Content -eq "ok") { return }
195
+ }
196
+ catch { }
197
+ Start-Sleep -Milliseconds 250
198
+ }
199
+ throw "Relay health timed out; inspect the private relay logs."
200
+ }
201
+
202
+ function Set-Webhook {
203
+ Wait-Relay
204
+ # Docker Desktop routes this name to Windows. Verify the actual route, never broaden the bind.
205
+ $check = "fetch('http://host.docker.internal:$($state.ports.relay)/healthz').then(async r=>process.exit(r.ok&&(await r.text())==='ok'?0:1)).catch(()=>process.exit(1))"
206
+ Invoke-Compose @("exec", "-T", "evolution", "node", "-e", $check)
207
+ $body = @{ webhook = @{ enabled = $true; byEvents = $false; base64 = $false;
208
+ url = "http://host.docker.internal:$($state.ports.relay)/v1/webhooks/whatsapp/evolution";
209
+ headers = @{ "x-aegis-webhook-token" = $state.secrets.webhook }; events = @("MESSAGES_UPSERT") } }
210
+ $null = Invoke-Evolution "POST" "webhook/set/$($state.instance)" $body
211
+ Write-Host "Authenticated Evolution webhook registered."
212
+ }
213
+
214
+ if ($Action -eq "init") {
215
+ $aegisDirectory = Join-Path $Workspace ".arun"
216
+ if ((Test-Path -LiteralPath $aegisDirectory) -and
217
+ ((Get-Item -LiteralPath $aegisDirectory).Attributes -band [IO.FileAttributes]::ReparsePoint)) {
218
+ throw "Aegis workspace data directory cannot be a link."
219
+ }
220
+ if (-not (Test-Path -LiteralPath $aegisDirectory)) { New-Item -ItemType Directory -Path $aegisDirectory | Out-Null }
221
+ Protect-Directory $stateDirectory
222
+ if (Test-Path -LiteralPath $stateFile) { Write-Host "Local setup already initialized; credentials preserved."; return }
223
+ $nativeAegis = Get-InstalledAegis
224
+ Push-Location -LiteralPath $Workspace
225
+ try {
226
+ $installation = (& $nativeAegis remote identity | Out-String).Trim()
227
+ if ($LASTEXITCODE -ne 0) { throw "Installed Aegis could not return its identity." }
228
+ }
229
+ finally { Pop-Location }
230
+ $parsed = [guid]::Empty
231
+ if (-not [guid]::TryParseExact($installation, "D", [ref]$parsed) -or $parsed.ToString("D") -cne $installation) {
232
+ throw "Installed Aegis did not return a canonical installation UUID."
233
+ }
234
+ $ports = @()
235
+ while ($ports.Count -lt 4) { $candidate = Get-FreePort; if ($candidate -notin $ports) { $ports += $candidate } }
236
+ $state = @{ version = 1; project = "aegis-local-$($parsed.ToString('N'))"; installation_id = $installation;
237
+ workspace = $Workspace; aegis_binary = $nativeAegis; instance = "aegis-local";
238
+ ports = @{ database = $ports[0]; nats = $ports[1]; evolution = $ports[2]; relay = $ports[3] };
239
+ secrets = @{ relay_database = New-Secret; evolution_database = New-Secret; nats_relay = New-Secret;
240
+ nats_device = New-Secret; relay_admin = New-Secret; evolution_api = New-Secret; webhook = New-Secret } }
241
+ Write-PrivateFile $stateFile ($state | ConvertTo-Json -Depth 5)
242
+ Write-Host "Initialized local remote setup for installed Aegis. Credentials are private and excluded from Git."
243
+ return
244
+ }
245
+
246
+ if (-not (Test-Path -LiteralPath $stateFile -PathType Leaf)) { throw "Run local-remote.ps1 init first." }
247
+ foreach ($path in @((Join-Path $Workspace ".arun"), $stateFile)) {
248
+ if ((Get-Item -LiteralPath $path).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "Local remote state cannot be a link." }
249
+ }
250
+ Protect-Directory $stateDirectory
251
+ $state = Get-Content -LiteralPath $stateFile -Raw | ConvertFrom-Json
252
+ if ($state.version -ne 1 -or $state.workspace -cne $Workspace -or $state.project -notmatch '^aegis-local-[0-9a-f]{32}$') {
253
+ throw "Local remote state does not match this workspace."
254
+ }
255
+ $values = Get-Environment
256
+ $lines = foreach ($name in ($values.Keys | Sort-Object)) {
257
+ $value = [string]$values[$name]
258
+ if ($value.Contains("'") -or $value.Contains("`n") -or $value.Contains("`r")) { throw "Invalid local environment value." }
259
+ "$name='$value'"
260
+ }
261
+ Write-PrivateFile $envFile ($lines -join "`n")
262
+
263
+ switch ($Action) {
264
+ "start" {
265
+ $certDirectory = Join-Path $stateDirectory "nats-certs"
266
+ Protect-Directory $certDirectory
267
+ if (-not (Test-Path -LiteralPath (Join-Path $certDirectory "nats.crt"))) {
268
+ & (Join-Path $PSScriptRoot "make-nats-certs.ps1") -OutputDirectory $certDirectory
269
+ if ($LASTEXITCODE -ne 0) { throw "NATS certificate generation failed." }
270
+ }
271
+ Invoke-Compose @("config", "--quiet")
272
+ if (-not (Get-OwnedProcess "relay") -and -not $RelayBinary -and
273
+ $nativeRelay -ceq (Join-Path $repoRoot "relay\target\debug\aegis-relay.exe")) {
274
+ if (-not (Test-Path -LiteralPath (Join-Path $repoRoot "relay\Cargo.toml"))) { throw "Install the native relay executable or pass -RelayBinary." }
275
+ & cargo build --offline --manifest-path (Join-Path $repoRoot "relay\Cargo.toml") --config profile.dev.debug=0 -j 1
276
+ if ($LASTEXITCODE -ne 0) { throw "Relay build failed." }
277
+ }
278
+ Invoke-Compose @("up", "-d", "--wait", "--wait-timeout", "180")
279
+ Start-OwnedProcess "relay" $nativeRelay @()
280
+ Wait-Relay
281
+ Write-Host "Local services ready. Run create-instance, then qr to prepare phone linking."
282
+ }
283
+ "stop" {
284
+ foreach ($name in @("daemon", "relay")) {
285
+ $owned = Get-OwnedProcess $name
286
+ if ($owned) { Stop-Process -Id $owned.Id -Force; $owned.WaitForExit() }
287
+ }
288
+ Invoke-Compose @("stop")
289
+ Write-Host "Local services stopped; credentials, WhatsApp session and volumes preserved."
290
+ }
291
+ "status" {
292
+ Invoke-Compose @("ps")
293
+ foreach ($name in @("relay", "daemon")) { Write-Host "$name running: $([bool](Get-OwnedProcess $name))" }
294
+ Write-Host "Evolution: http://127.0.0.1:$($state.ports.evolution)"
295
+ Write-Host "Relay: http://127.0.0.1:$($state.ports.relay)/healthz"
296
+ Write-Host "NATS: tls://localhost:$($state.ports.nats)"
297
+ try { $connection = Invoke-Evolution "GET" "instance/connectionState/$($state.instance)"; Write-Host "WhatsApp state: $($connection.instance.state)" }
298
+ catch { Write-Host "WhatsApp instance not ready or not created." }
299
+ }
300
+ "create-instance" {
301
+ if (-not (Get-LocalInstance)) {
302
+ $null = Invoke-Evolution "POST" "instance/create" @{ instanceName = $state.instance; integration = "WHATSAPP-BAILEYS";
303
+ qrcode = $false; groupsIgnore = $false; readMessages = $false; readStatus = $false; syncFullHistory = $false }
304
+ }
305
+ Set-Webhook
306
+ Write-Host "Aegis WhatsApp instance exists. It is not linked until the QR is scanned."
307
+ }
308
+ "webhook" { Set-Webhook }
309
+ "self-account" {
310
+ $instance = Get-LocalInstance
311
+ if (-not $instance) { throw "Create and link the Evolution instance first." }
312
+ $ownerProperty = $instance.PSObject.Properties["ownerJid"]
313
+ $connectionProperty = $instance.PSObject.Properties["connectionStatus"]
314
+ if (-not $ownerProperty -or -not $ownerProperty.Value -or
315
+ -not $connectionProperty -or $connectionProperty.Value -ne "open") {
316
+ throw "The WhatsApp account is not linked yet. Scan a fresh QR before enabling self-account mode."
317
+ }
318
+ $ownerJid = [string]$ownerProperty.Value
319
+ if ($ownerJid -notmatch '^(\d{8,15})(?::\d+)?@s\.whatsapp\.net$') { throw "Evolution did not report a canonical owner phone JID." }
320
+ $ownerPhone = "+$($Matches[1])"
321
+ $state | Add-Member -NotePropertyName self_account_owner -NotePropertyValue $ownerPhone -Force
322
+ Write-PrivateFile $stateFile ($state | ConvertTo-Json -Depth 5)
323
+ $owned = Get-OwnedProcess "relay"
324
+ if ($owned) { Stop-Process -Id $owned.Id -Force; $owned.WaitForExit() }
325
+ Start-OwnedProcess "relay" $nativeRelay @()
326
+ Wait-Relay
327
+ Set-Webhook
328
+ Write-Host "Self-account mode enabled for the linked owner. Use WhatsApp Message yourself, then the known Aegis task groups."
329
+ }
330
+ "qr" {
331
+ $base64 = $null
332
+ for ($attempt = 0; $attempt -lt 20; $attempt++) {
333
+ $response = Invoke-Evolution "GET" "instance/connect/$($state.instance)"
334
+ if ($response.PSObject.Properties.Name -contains "base64") { $base64 = $response.base64; if ($base64) { break } }
335
+ Start-Sleep -Milliseconds 500
336
+ }
337
+ if (-not $base64 -or $base64 -notmatch '^data:image/png;base64,([A-Za-z0-9+/=]+)$') {
338
+ throw "No fresh QR available. Check status; an already linked instance needs no QR."
339
+ }
340
+ [IO.File]::WriteAllBytes((Join-Path $stateDirectory "whatsapp-qr.png"), [Convert]::FromBase64String($Matches[1]))
341
+ $html = '<!doctype html><meta charset="utf-8"><title>Aegis WhatsApp linking</title><style>body{font:18px system-ui;background:#111;color:#eee;text-align:center;padding:40px}img{background:white;padding:20px;max-width:80vw}</style><h1>Link WhatsApp to Aegis</h1><p>WhatsApp &rarr; Settings &rarr; Linked devices &rarr; Link a device.</p><img alt="WhatsApp linking QR" src="' + $base64 + '"><p>This QR expires. Run the qr command again for a fresh one.</p>'
342
+ Write-PrivateFile (Join-Path $stateDirectory "whatsapp-qr.html") $html
343
+ Write-Host "Fresh QR saved: $(Join-Path $stateDirectory 'whatsapp-qr.html')"
344
+ }
345
+ "pair" {
346
+ Wait-Relay
347
+ Push-Location -LiteralPath $Workspace
348
+ try {
349
+ Invoke-WithEnvironment $values {
350
+ & $state.aegis_binary remote pair --relay-admin-url "http://127.0.0.1:$($state.ports.relay)" `
351
+ --admin-token-env AEGIS_LOCAL_RELAY_ADMIN_TOKEN --nats-url "tls://localhost:$($state.ports.nats)" `
352
+ --nats-token-env AEGIS_LOCAL_NATS_TOKEN --nats-root-cert (Join-Path $stateDirectory "nats-certs\nats.crt")
353
+ if ($LASTEXITCODE -ne 0) { throw "Installed Aegis pairing failed." }
354
+ }
355
+ }
356
+ finally { Pop-Location }
357
+ }
358
+ "daemon" {
359
+ Wait-Relay
360
+ if (-not (Test-Path -LiteralPath (Join-Path $Workspace ".arun\remote.json"))) { throw "Run pair before daemon." }
361
+ Start-OwnedProcess "daemon" $state.aegis_binary @("remote", "run")
362
+ Write-Host "Aegis remote daemon started with local device credentials."
363
+ }
364
+ }
@@ -0,0 +1,46 @@
1
+ param([string]$OutputDirectory)
2
+
3
+ $ErrorActionPreference = "Stop"
4
+
5
+ $certDirectory = if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
6
+ Join-Path $PSScriptRoot "certs"
7
+ }
8
+ else {
9
+ [System.IO.Path]::GetFullPath($OutputDirectory)
10
+ }
11
+ New-Item -ItemType Directory -Force -Path $certDirectory | Out-Null
12
+ $certificate = Join-Path $certDirectory "nats.crt"
13
+ $privateKey = Join-Path $certDirectory "nats.key"
14
+
15
+ $openssl = Get-Command openssl -ErrorAction SilentlyContinue
16
+ if ($openssl) {
17
+ $previousOpenSslConfig = [Environment]::GetEnvironmentVariable("OPENSSL_CONF", "Process")
18
+ try {
19
+ if ($previousOpenSslConfig -and -not (Test-Path -LiteralPath $previousOpenSslConfig -PathType Leaf)) {
20
+ $candidateConfigs = @(
21
+ (Join-Path (Split-Path $openssl.Source -Parent) "..\ssl\openssl.cnf"),
22
+ (Join-Path $env:ProgramFiles "Git\mingw64\etc\ssl\openssl.cnf"),
23
+ (Join-Path $env:ProgramFiles "Common Files\SSL\openssl.cnf")
24
+ )
25
+ $usableConfig = $candidateConfigs |
26
+ Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } |
27
+ Select-Object -First 1
28
+ if ($usableConfig) {
29
+ $env:OPENSSL_CONF = (Resolve-Path -LiteralPath $usableConfig).Path
30
+ }
31
+ else {
32
+ Remove-Item Env:OPENSSL_CONF -ErrorAction SilentlyContinue
33
+ }
34
+ }
35
+ & $openssl.Source req -x509 -newkey rsa:2048 -nodes -sha256 -days 365 -keyout $privateKey -out $certificate -subj "/CN=nats" -addext "basicConstraints=critical,CA:FALSE" -addext "keyUsage=critical,digitalSignature,keyEncipherment" -addext "extendedKeyUsage=serverAuth" -addext "subjectAltName=DNS:nats,DNS:localhost,IP:127.0.0.1"
36
+ }
37
+ finally {
38
+ [Environment]::SetEnvironmentVariable("OPENSSL_CONF", $previousOpenSslConfig, "Process")
39
+ }
40
+ }
41
+ else {
42
+ & docker run --rm --mount "type=bind,source=$certDirectory,target=/certs" alpine:latest sh -c "apk add --no-cache openssl >/dev/null && openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 365 -keyout /certs/nats.key -out /certs/nats.crt -subj '/CN=nats' -addext 'basicConstraints=critical,CA:FALSE' -addext 'keyUsage=critical,digitalSignature,keyEncipherment' -addext 'extendedKeyUsage=serverAuth' -addext 'subjectAltName=DNS:nats,DNS:localhost,IP:127.0.0.1'"
43
+ }
44
+ if ($LASTEXITCODE -ne 0) {
45
+ throw "OpenSSL could not generate the local NATS certificate. Install OpenSSL or make Docker available."
46
+ }
@@ -0,0 +1,44 @@
1
+ server_name: aegis-local-remote
2
+ listen: 0.0.0.0:4222
3
+ authorization {
4
+ users: [
5
+ {
6
+ user: aegis-relay
7
+ password: $NATS_AUTH_TOKEN
8
+ permissions: {
9
+ publish: { allow: [
10
+ "aegis.commands.*"
11
+ "$JS.API.STREAM.CREATE.AEGIS_COMMANDS"
12
+ "$JS.API.STREAM.UPDATE.AEGIS_COMMANDS"
13
+ "$JS.API.STREAM.CREATE.AEGIS_EVENTS"
14
+ "$JS.API.STREAM.UPDATE.AEGIS_EVENTS"
15
+ "$JS.API.STREAM.INFO.AEGIS_EVENTS"
16
+ "$JS.API.CONSUMER.INFO.AEGIS_EVENTS.relay-whatsapp-delivery"
17
+ "$JS.API.CONSUMER.CREATE.AEGIS_EVENTS.relay-whatsapp-delivery.aegis.events.*"
18
+ "$JS.API.CONSUMER.MSG.NEXT.AEGIS_EVENTS.relay-whatsapp-delivery"
19
+ "$JS.ACK.AEGIS_EVENTS.relay-whatsapp-delivery.*.*.*.*.*"
20
+ ] }
21
+ subscribe: { allow: ["_INBOX.aegis.relay.>"] }
22
+ }
23
+ }
24
+ {
25
+ user: $AEGIS_NATS_DEVICE_USERNAME
26
+ password: $AEGIS_NATS_DEVICE_PASSWORD
27
+ permissions: {
28
+ publish: { allow: [
29
+ $AEGIS_NATS_DEVICE_EVENT_SUBJECT
30
+ $AEGIS_NATS_DEVICE_CONSUMER_INFO
31
+ $AEGIS_NATS_DEVICE_CONSUMER_CREATE
32
+ $AEGIS_NATS_DEVICE_CONSUMER_NEXT
33
+ $AEGIS_NATS_DEVICE_ACK
34
+ ] }
35
+ subscribe: { allow: [$AEGIS_NATS_DEVICE_INBOX] }
36
+ }
37
+ }
38
+ ]
39
+ }
40
+ jetstream { store_dir: /data/jetstream }
41
+ tls {
42
+ cert_file: /run/certs/nats.crt
43
+ key_file: /run/certs/nats.key
44
+ }
@@ -0,0 +1,71 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { realpath } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { platformTarget } from './platform.mjs';
7
+
8
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
9
+ const actions = new Set(['init', 'start', 'stop', 'status', 'create-instance', 'webhook', 'qr', 'self-account', 'pair', 'daemon']);
10
+
11
+ export function parseWhatsAppArgs(args) {
12
+ const action = args[0] ?? 'help';
13
+ if (['help', '--help', '-h'].includes(action)) return { action: 'help' };
14
+ if (!actions.has(action)) throw new Error(`Unknown WhatsApp action: ${action}. Run aegis whatsapp help.`);
15
+ let workspace = process.cwd();
16
+ for (let index = 1; index < args.length; index++) {
17
+ if (args[index] !== '--workspace' || !args[index + 1]) throw new Error('Usage: aegis whatsapp <action> [--workspace <directory>]');
18
+ workspace = args[++index];
19
+ }
20
+ return { action, workspace };
21
+ }
22
+
23
+ export async function whatsappCommand(args, executable) {
24
+ const options = parseWhatsAppArgs(args);
25
+ if (options.action === 'help') {
26
+ console.log(`Aegis WhatsApp for Windows (Docker Desktop required)
27
+
28
+ aegis whatsapp <action> [--workspace <directory>]
29
+
30
+ init Create private local settings and TLS credentials
31
+ start Start the persistent gateway and relay
32
+ create-instance Create the WhatsApp linked-device instance
33
+ qr Save the QR image to scan from WhatsApp Linked devices
34
+ self-account After linking, restrict commands to your own account
35
+ pair Create a one-time Aegis pairing code
36
+ daemon Start Aegis task control for this workspace
37
+ status Show local service and linked-device status
38
+ stop Stop this workspace's local services
39
+ webhook Reapply the gateway webhook settings
40
+
41
+ Use self-account after scanning the QR, then send the pairing code in your
42
+ WhatsApp self-chat. Task groups are requested automatically; group creation
43
+ using only your own number still needs verification with the linked account.
44
+ Send /help to discover commands or /goal <task> to start work.`);
45
+ return 0;
46
+ }
47
+ if (process.platform !== 'win32') throw new Error('The local WhatsApp controller requires Windows and Docker Desktop.');
48
+ const workspace = await realpath(options.workspace);
49
+ const script = path.join(root, 'relay', 'local', 'local-remote.ps1');
50
+ if (!existsSync(script)) throw new Error('This installation is missing the WhatsApp controller. Reinstall the current Aegis package.');
51
+ const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
52
+ const command = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script,
53
+ '-Action', options.action, '-Workspace', workspace, '-AegisBinary', executable];
54
+ const relay = path.join(root, 'vendor', platformTarget().target, 'aegis-relay.exe');
55
+ if (existsSync(relay)) command.push('-RelayBinary', relay);
56
+ const child = spawn(powershell, command, { stdio: 'inherit', windowsHide: true });
57
+ const stop = signal => { if (!child.killed) child.kill(signal); };
58
+ const onInterrupt = () => stop('SIGINT');
59
+ const onTerminate = () => stop('SIGTERM');
60
+ process.on('SIGINT', onInterrupt);
61
+ process.on('SIGTERM', onTerminate);
62
+ try {
63
+ return await new Promise((resolve, reject) => {
64
+ child.once('error', reject);
65
+ child.once('close', (code, signal) => resolve(signal ? 1 : code ?? 1));
66
+ });
67
+ } finally {
68
+ process.removeListener('SIGINT', onInterrupt);
69
+ process.removeListener('SIGTERM', onTerminate);
70
+ }
71
+ }
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { spawn } from 'node:child_process';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { tools } from './windows-host.mjs';
8
+
9
+ const argv = process.argv.slice(2), options = {};
10
+ try {
11
+ for (let index = 0; index < argv.length; index++) {
12
+ const flag = argv[index];
13
+ if (flag === '--trusted-host') options.trusted = true;
14
+ else if (['--workspace', '--aegis'].includes(flag) && argv[index + 1]) options[flag.slice(2)] = argv[++index];
15
+ else throw new Error(`Unknown or incomplete option: ${flag}`);
16
+ }
17
+ if (!options.trusted) throw new Error('Requires --trusted-host: explicitly grants all listed Windows host tools to NEW workspace tasks.');
18
+ if (process.platform !== 'win32') throw new Error('Windows host requires Windows');
19
+ const workspace = await fs.realpath(path.resolve(options.workspace ?? '.'));
20
+ if (!(await fs.stat(workspace)).isDirectory()) throw new Error('Workspace must be an existing directory');
21
+ const profilePath = path.join(workspace, '.arun', 'profile.json');
22
+ const readProfile = async () => fs.readFile(profilePath, 'utf8').catch(error => {
23
+ if (error.code === 'ENOENT') return null;
24
+ throw error;
25
+ });
26
+ const before = await readProfile();
27
+ const profile = before === null ? { provider: 'chatgpt', model: null, endpoint: null, write: false, image: null }
28
+ : JSON.parse(before.replace(/^\uFEFF/, ''));
29
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile) ||
30
+ (profile.mcp_grants !== undefined && (!Array.isArray(profile.mcp_grants) || profile.mcp_grants.some(grant => typeof grant !== 'string')))) {
31
+ throw new Error('Existing profile has invalid MCP grants; refusing to replace it');
32
+ }
33
+ const server = fileURLToPath(new URL('./windows-host.mjs', import.meta.url));
34
+ const launcher = fileURLToPath(new URL('../bin/aegis.mjs', import.meta.url));
35
+ const args = ['mcp', 'add', 'windows-host', '--trusted-host', process.execPath, server, '--trusted-host'];
36
+ if (options.aegis && !path.isAbsolute(options.aegis)) throw new Error('--aegis must be an absolute native executable path');
37
+ const executable = options.aegis ?? process.execPath;
38
+ const nativeArgs = options.aegis ? args : [launcher, ...args];
39
+ await new Promise((resolve, reject) => {
40
+ const child = spawn(executable, nativeArgs, { cwd: workspace, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
41
+ let stderr = '';
42
+ child.stdout.on('data', chunk => process.stdout.write(chunk));
43
+ child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString('utf8')).slice(-8192); });
44
+ const timer = setTimeout(() => { child.kill(); reject(new Error('Aegis MCP registration timed out')); }, 25000);
45
+ child.on('error', error => { clearTimeout(timer); reject(error); });
46
+ child.on('close', code => { clearTimeout(timer); code === 0 ? resolve() : reject(new Error(`MCP registration failed (${code}): ${stderr}`)); });
47
+ });
48
+ if (await readProfile() !== before) throw new Error('Profile changed during registration; registry saved but grants were not changed. Rerun after settings are saved.');
49
+ profile.mcp_grants = [...new Set([...(profile.mcp_grants ?? []), ...tools.map(tool => `mcp:windows-host:${tool.name}`)])];
50
+ await fs.mkdir(path.dirname(profilePath), { recursive: true });
51
+ const temporary = `${profilePath}.windows-host-${randomUUID()}.tmp`;
52
+ try {
53
+ await fs.writeFile(temporary, `${JSON.stringify(profile, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
54
+ await fs.rename(temporary, profilePath);
55
+ } finally { await fs.unlink(temporary).catch(() => {}); }
56
+ console.log(`Registered ${tools.length} trusted Windows host tools for new tasks in ${workspace}. Existing task grants remain frozen.`);
57
+ } catch (error) {
58
+ console.error(`windows-host: ${error.message}`);
59
+ process.exitCode = 1;
60
+ }