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.
- package/README.md +29 -263
- package/bin/aegis.mjs +31 -11
- package/package.json +36 -6
- package/relay/README.md +177 -0
- package/relay/local/README.md +47 -0
- package/relay/local/compose.yaml +132 -0
- package/relay/local/local-remote.ps1 +364 -0
- package/relay/local/make-nats-certs.ps1 +46 -0
- package/relay/local/nats-server.conf +44 -0
- package/scripts/whatsapp-cli.mjs +71 -0
- package/scripts/windows-host-install.mjs +60 -0
- package/scripts/windows-host.mjs +246 -0
- package/scripts/windows-host.ps1 +230 -0
- package/vendor/x86_64-pc-windows-msvc/arun.exe +0 -0
|
@@ -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 → Settings → Linked devices → 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
|
+
}
|