13 Commits

Author SHA1 Message Date
b9878e938c Clarify flight-finder cron completion rules 2026-04-01 10:21:14 -05:00
b2e97a3036 Polish flight-finder PDF layout 2026-03-30 19:54:35 -05:00
809a3955e5 Improve flight-finder report links and cron workflow 2026-03-30 18:26:09 -05:00
fb868b9e5f fix(flight-finder): require fresh search evidence and improve PDF layout 2026-03-30 17:45:32 -05:00
e2657f4850 feat(flight-finder): implement milestone M3 - prompt migration and smoke test fixtures 2026-03-30 17:12:52 -05:00
c30ad85e0d feat(flight-finder): implement milestone M2 - report workflow and delivery gates 2026-03-30 17:00:09 -05:00
ba5b0e4e67 chore(flight-finder): stop tracking installed dependencies 2026-03-30 16:46:06 -05:00
9c7103770a feat(flight-finder): implement milestone M1 - domain model and skill contract 2026-03-30 16:45:40 -05:00
57f6b132b2 Fix NordVPN DNS and Tailscale recovery interlock 2026-03-30 14:29:39 -05:00
b3a59b5b45 fix(nordvpn-client): validate live utun persistence before dns pinning 2026-03-30 12:08:25 -05:00
a796481875 feat(nordvpn-client): gate macos connect on stable wireguard persistence 2026-03-30 11:55:20 -05:00
8d2c162849 feat(nordvpn-client): implement milestone M1 diagnostics and classification 2026-03-30 11:34:20 -05:00
4919edcec1 Fix ACP startup guidance for managed acpx path 2026-03-30 08:05:53 -05:00
34 changed files with 4590 additions and 90 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
.worktrees/ .worktrees/
node_modules/

View File

@@ -17,6 +17,7 @@ This repository contains practical OpenClaw skills and companion integrations. I
| Skill | What it does | Path | | Skill | What it does | Path |
|---|---|---| |---|---|---|
| `elevenlabs-stt` | Transcribe local audio files with ElevenLabs Speech-to-Text, with diarization, language hints, event tags, and JSON output. | `skills/elevenlabs-stt` | | `elevenlabs-stt` | Transcribe local audio files with ElevenLabs Speech-to-Text, with diarization, language hints, event tags, and JSON output. | `skills/elevenlabs-stt` |
| `flight-finder` | Collect structured flight-search inputs, run bounded multi-source travel searches, rank results in USD, and gate fixed-template PDF/email delivery through Lukes sender path. | `skills/flight-finder` |
| `gitea-api` | Interact with Gitea via REST API (repos, issues, PRs, releases, branches, user info). | `skills/gitea-api` | | `gitea-api` | Interact with Gitea via REST API (repos, issues, PRs, releases, branches, user info). | `skills/gitea-api` |
| `nordvpn-client` | Install, log in to, connect, disconnect, and verify NordVPN sessions across Linux CLI and macOS NordLynx/WireGuard backends. | `skills/nordvpn-client` | | `nordvpn-client` | Install, log in to, connect, disconnect, and verify NordVPN sessions across Linux CLI and macOS NordLynx/WireGuard backends. | `skills/nordvpn-client` |
| `portainer` | Manage Portainer stacks via API (list, start/stop/restart, update, prune images). | `skills/portainer` | | `portainer` | Manage Portainer stacks via API (list, start/stop/restart, update, prune images). | `skills/portainer` |

View File

@@ -5,6 +5,7 @@ This folder contains detailed docs for each skill in this repository.
## Skills ## Skills
- [`elevenlabs-stt`](elevenlabs-stt.md) — Local audio transcription through ElevenLabs Speech-to-Text - [`elevenlabs-stt`](elevenlabs-stt.md) — Local audio transcription through ElevenLabs Speech-to-Text
- [`flight-finder`](flight-finder.md) — Typed flight-search intake, bounded source orchestration, PDF report rendering, and Luke-sender email delivery
- [`gitea-api`](gitea-api.md) — REST-based Gitea automation (no `tea` CLI required) - [`gitea-api`](gitea-api.md) — REST-based Gitea automation (no `tea` CLI required)
- [`nordvpn-client`](nordvpn-client.md) — Cross-platform NordVPN install, login, connect, disconnect, and verification with Linux CLI and macOS NordLynx/WireGuard support - [`nordvpn-client`](nordvpn-client.md) — Cross-platform NordVPN install, login, connect, disconnect, and verification with Linux CLI and macOS NordLynx/WireGuard support
- [`portainer`](portainer.md) — Portainer stack management (list, lifecycle, updates, image pruning) - [`portainer`](portainer.md) — Portainer stack management (list, lifecycle, updates, image pruning)

77
docs/flight-finder.md Normal file
View File

@@ -0,0 +1,77 @@
# flight-finder
Reusable flight-search report skill for OpenClaw. It replaces the brittle one-off `dfw-blq-2026.md` prompt with typed intake, bounded source orchestration, explicit report gates, fixed-template PDF output, and Luke-sender email delivery.
## Core behavior
`flight-finder` is designed to:
- collect missing trip inputs explicitly instead of relying on hardcoded prompt prose
- support split returns, flexible dates, passenger groups, and exclusions
- use bounded search phases across KAYAK, Skyscanner, Expedia, and a best-effort airline direct cross-check
- normalize pricing to USD before ranking
- produce a report payload first, then render PDF/email only when the report is complete
- render booking links in the PDF for each recommended fare, including airline-direct links when they were actually captured
- behave safely on WhatsApp-style chat surfaces by treating status nudges as updates, not resets
- require a fresh bounded search run for every report instead of reusing earlier captures
## Important rules
- Recipient email is a delivery gate, not a search gate.
- Cached flight-search data is forbidden as primary evidence for a new run.
- same-day workspace captures must not be reused
- previously rendered PDFs must not be treated as fresh search output
- if fresh search fails, the skill must degrade honestly instead of recycling earlier artifacts
- `marketCountry` is explicit-only in this implementation pass.
- It must be an ISO 3166-1 alpha-2 uppercase code such as `TH` or `DE`.
- If present, it activates VPN only for the bounded search phase.
- If omitted, no VPN change happens.
- If VPN connect or verification fails, the run falls back to the default market and records a degraded warning instead of hanging.
- Direct-airline cross-checking is currently best-effort, not a hard blocker.
- The PDF should show:
- the captured booking/search link for every quoted fare
- the airline-direct link only when `directBookingUrl` was actually captured
- an explicit “not captured in this run” note when direct-airline booking was unavailable
## DFW ↔ BLQ daily automation
- The 6 AM DFW ↔ BLQ automation should invoke `flight-finder` directly with the structured JSON payload.
- The legacy file at `workspace/docs/prompts/dfw-blq-2026.md` should mirror that exact direct skill invocation, not old prose instructions.
## Helper package
From `skills/flight-finder/`:
```bash
npm install
npm run normalize-request -- --legacy-dfw-blq
npm run normalize-request -- --input "<request.json>"
npm run report-status -- --input "<report-payload.json>"
npm run render-report -- --input "<report-payload.json>" --output "<report.pdf>"
npm run delivery-plan -- --to "<recipient@example.com>" --subject "<subject>" --body "<body>" --attach "<report.pdf>"
```
Expected report-payload provenance fields:
- `searchExecution.freshSearch: true`
- `searchExecution.startedAt`
- `searchExecution.completedAt`
- `searchExecution.artifactsRoot`
## Delivery
- sender identity: `luke@fiorinis.com`
- send path:
- `zsh ~/.openclaw/workspace/bin/gog-luke gmail send --to "<target>" --subject "<subject>" --body "<body>" --attach "<pdf-path>"`
- if the user already provided the destination email, that counts as delivery authorization once the report is ready
## Source viability for implementation pass 1
Bounded checks on Stefano's MacBook Air showed:
- `KAYAK`: viable
- `Skyscanner`: viable
- `Expedia`: viable
- airline direct-booking cross-check: degraded / best-effort
That means the first implementation pass should rely primarily on the three aggregator sources and treat direct-airline confirmation as additive evidence when it succeeds.

View File

@@ -69,13 +69,19 @@ Current macOS backend:
- NordLynx/WireGuard - NordLynx/WireGuard
- `wireguard-go` - `wireguard-go`
- `wireguard-tools` - `wireguard-tools`
- NordVPN DNS in the generated WireGuard config: - explicit macOS DNS management on eligible physical services:
- `103.86.96.100` - `103.86.96.100`
- `103.86.99.100` - `103.86.99.100`
Important behavior: Important behavior:
- `NordVPN.app` may remain installed, but the automated backend does not reuse app login state. - `NordVPN.app` may remain installed, but the automated backend does not reuse app login state.
- the generated WireGuard config intentionally stays free of `DNS = ...` so `wg-quick` does not rewrite every macOS network service behind the skills back.
- during `connect`, the skill first proves the tunnel is stable with a bounded persistence gate that reuses the allowed helper `probe` action and a verified public exit.
- during `connect`, the skill snapshots current DNS/search-domain settings on eligible physical services and then applies NordVPN DNS only after that stable gate, one last liveness check, and a post-DNS system-hostname-resolution check succeed.
- during `disconnect`, or after a failed/stale teardown, the skill restores the saved DNS/search-domain snapshot.
- if persistence, exit verification, or post-DNS hostname resolution fails, the skill rolls back before treating the connect as successful and resumes Tailscale if it stopped it.
- when the skill intentionally stops Tailscale for a VPN session, it writes a short-lived suppression marker so host watchdogs do not immediately run `tailscale up` and fight the VPN route change.
- The skill automatically suspends Tailscale before connect if Tailscale is active. - The skill automatically suspends Tailscale before connect if Tailscale is active.
- The skill resumes Tailscale after disconnect, or after a failed connect, if it stopped it. - The skill resumes Tailscale after disconnect, or after a failed connect, if it stopped it.
- The Homebrew NordVPN app does not need to be uninstalled. - The Homebrew NordVPN app does not need to be uninstalled.
@@ -144,6 +150,8 @@ Add this exact rule:
stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh probe, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh up, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh down stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh probe, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh up, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh down
``` ```
Do not add extra helper actions just for persistence checks unless you are also updating host sudoers. The current implementation intentionally rides the persistence check on `probe` so the existing `probe/up/down` rule remains sufficient.
If you run the repo copy directly instead of the installed OpenClaw skill, adjust the helper path accordingly. If you run the repo copy directly instead of the installed OpenClaw skill, adjust the helper path accordingly.
## Common Flows ## Common Flows
@@ -188,7 +196,9 @@ Expected macOS behavior:
- stop Tailscale if active - stop Tailscale if active
- select a NordVPN server for the target - select a NordVPN server for the target
- bring up the WireGuard tunnel - bring up the WireGuard tunnel
- prove persistence of the live `utun*` runtime via the helper `probe` path
- verify the public exit location - verify the public exit location
- run one final liveness check before applying NordVPN DNS
- return JSON describing the chosen server and final verified location - return JSON describing the chosen server and final verified location
### Verify ### Verify
@@ -209,6 +219,7 @@ Expected macOS behavior:
- attempt `wg-quick down` whenever there is active or residual NordVPN WireGuard state - attempt `wg-quick down` whenever there is active or residual NordVPN WireGuard state
- remove stale local NordVPN state files after teardown - remove stale local NordVPN state files after teardown
- restore automatic DNS when the saved DNS snapshot is obviously just NordVPN-pinned leftovers
- resume Tailscale if the skill had suspended it - resume Tailscale if the skill had suspended it
## Output Model ## Output Model
@@ -238,7 +249,9 @@ For deeper troubleshooting, use:
node skills/nordvpn-client/scripts/nordvpn-client.js status --debug node skills/nordvpn-client/scripts/nordvpn-client.js status --debug
``` ```
`--debug` keeps the internal local paths and other low-level metadata in the JSON output. `--debug` keeps the internal local paths, helper-hardening diagnostics, and other low-level metadata in the JSON output.
If you also run local watchdogs such as `healthwatch.sh`, they should honor the NordVPN Tailscale suppression marker at `~/.nordvpn-client/tailscale-suppressed` and skip automatic `tailscale up` while the marker is fresh or the NordVPN WireGuard tunnel is active.
## Troubleshooting ## Troubleshooting

View File

@@ -61,7 +61,6 @@ The current host-local OpenClaw config keeps the native `main` orchestrator and
- `acp.maxConcurrentSessions = 2` - `acp.maxConcurrentSessions = 2`
- `plugins.allow += acpx` - `plugins.allow += acpx`
- `plugins.entries.acpx.enabled = true` - `plugins.entries.acpx.enabled = true`
- `plugins.entries.acpx.config.command = /opt/homebrew/lib/node_modules/openclaw/dist/extensions/acpx/node_modules/acpx/dist/cli.js`
- ACP-specific `cwd` values are absolute paths, not `~`-prefixed shortcuts - ACP-specific `cwd` values are absolute paths, not `~`-prefixed shortcuts
The `main` entry is intentional. Once `agents.list` is populated, OpenClaw treats that list as the agent inventory. If `main` is omitted, ACP targets can displace the native orchestrator and break the intended architecture. The `main` entry is intentional. Once `agents.list` is populated, OpenClaw treats that list as the agent inventory. If `main` is omitted, ACP targets can displace the native orchestrator and break the intended architecture.
@@ -89,17 +88,19 @@ Important health nuance:
- `openclaw plugins inspect acpx --json` only tells you the plugin is loaded, not that the ACP backend is healthy enough for `sessions_spawn runtime:"acp"` - `openclaw plugins inspect acpx --json` only tells you the plugin is loaded, not that the ACP backend is healthy enough for `sessions_spawn runtime:"acp"`
- the actual readiness signal is the gateway log line `acpx runtime backend ready` - the actual readiness signal is the gateway log line `acpx runtime backend ready`
- during rollout, the backend stayed unavailable until ACP-specific `cwd` values were changed from `~/.openclaw/workspace` to absolute paths and the plugin command was pinned to the direct `acpx/dist/cli.js` entrypoint - during rollout, the backend stayed unavailable until ACP-specific `cwd` values were changed from `~/.openclaw/workspace` to absolute paths
- a later startup bug showed that pinning a custom `command` path disables the plugin-local managed install path and can leave ACP unavailable after a restart if the local `acpx` artifact is absent at boot
- the current host fix is to leave `plugins.entries.acpx.config.command` unset so the bundled plugin can manage its own plugin-local `acpx` binary
Maintenance note: Maintenance note:
- the pinned command path and `expectedVersion = "0.3.1"` are intentionally explicit because they were needed to get this host healthy - the current host intentionally uses the managed plugin-local default command path rather than a custom override
- after any OpenClaw upgrade, re-run: - after any OpenClaw upgrade, re-run:
- `openclaw config validate` - `openclaw config validate`
- `openclaw plugins inspect acpx --json` - `openclaw plugins inspect acpx --json`
- `openclaw logs --limit 80 --plain --timeout 10000 | rg 'acpx runtime backend (registered|ready|probe failed)'` - `openclaw logs --limit 80 --plain --timeout 10000 | rg 'acpx runtime backend (registered|ready|probe failed)'`
- `node /opt/homebrew/lib/node_modules/openclaw/dist/extensions/acpx/node_modules/acpx/dist/cli.js --version` - `ls -l /opt/homebrew/lib/node_modules/openclaw/dist/extensions/acpx/node_modules/.bin/acpx`
- if the pinned path or version no longer matches the bundled layout, update the config deliberately instead of assuming the old override remains valid - if ACP comes up unavailable at startup, check whether a custom `plugins.entries.acpx.config.command` override was reintroduced before debugging deeper
## Security Review ## Security Review

View File

@@ -0,0 +1,159 @@
---
name: flight-finder
description: Use when a user wants a flight-search report with explicit trip constraints, ranked options, and PDF/email delivery. Prefer when the request may include split returns, flexible dates, passenger groups, market-localized pricing, or a WhatsApp-safe workflow that should keep going after status nudges instead of hanging behind a long inline prompt.
---
# Flight Finder
Use this skill instead of the old `docs/prompts/dfw-blq-2026.md` prompt when the user wants a flight-search report.
The deliverable target is always a PDF report plus email delivery when the report is complete.
PDF output rule:
- Render aggregator booking links for every quoted fare.
- Render direct-airline booking links when they were actually captured.
- If a direct-airline link was not captured for a quote, say so explicitly in the report instead of implying one exists.
Freshness rule:
- Never reuse cached flight-search captures, prior same-day report payloads, earlier workspace artifacts, or previously rendered PDFs as the primary evidence for a new run.
- Every flight-finder run must execute a fresh bounded search before ranking, PDF render, or email delivery.
- If a fresh search cannot be completed, say so clearly and stop with a degraded/incomplete outcome instead of silently reusing old captures.
## Inputs
Accept either:
- a natural-language trip request
- a structured helper payload
- a route plus partial constraints that still need follow-up questions
Collect these explicitly when missing:
- origin and destination
- outbound date window
- whether there are one or more return legs
- passenger groups
- which passenger groups travel on which legs
- flexibility rules
- cabin / stop / layover preferences when relevant
- exclusions such as airlines, countries, or airports when relevant
- recipient email before final render/send
Optional explicit input:
- `marketCountry`
- if present, it must be a validated ISO 3166-1 alpha-2 uppercase country code such as `TH` or `DE`
- if present, it activates the VPN / market-localized search path for the bounded search phase only
- if absent, do not change VPN in this implementation pass
Do not silently infer or reuse missing trip-shape details from earlier runs just because the route looks similar.
If the user already explicitly said where to email the finished PDF, that counts as delivery authorization once the report is complete. Do not ask for a second send confirmation unless the destination changed or the user sounded uncertain.
## Core workflow
1. Normalize the trip request into typed legs, passenger groups, assignments, and preferences.
2. Ask targeted follow-up questions for missing search-critical inputs.
3. Run the bounded search phase across the configured travel sources.
4. Rank the viable combinations in USD.
5. Assemble the final report payload.
6. Render the PDF.
7. Send the email from Luke to the user-specified recipient.
Completion rule:
- A partial source result is not completion.
- A partial helper result is not completion.
- The workflow is complete only when the final report payload exists, the PDF is rendered, the email was sent, and any VPN cleanup required by the run has been attempted.
- An interim commentary/status line is not completion.
- For isolated cron-style runs, do not leave the run with only a phase-update summary. If PDF render or email send did not happen, the terminal result must say so explicitly as a failed or incomplete outcome.
## WhatsApp-safe behavior
Follow the same operational style as `property-assessor`:
- missing required input should trigger a direct question, not an invented assumption
- `update?`, `status?`, `and?`, and similar nudges mean “report status and keep going”
- a silent helper/source path is a failed path and should be abandoned quickly
- previously saved workspace captures are not a fallback; they are stale evidence and must not be reused for a new report
- keep progress state by phase so a dropped session can resume or at least report the last completed phase
- do not start email-tool exploration before the report payload is complete and the PDF is ready to render
- if PDF render fails, return the completed report summary in chat and report delivery failure separately
- if email send fails after render, say so clearly and keep the rendered PDF path
## Long-running command rule
- Do not fire a long-running local command and then abandon it.
- If an `exec` call returns a running session/process handle instead of a completed result, immediately follow it with the relevant process polling/log calls until the command finishes, fails, or times out.
- Treat VPN connect, fresh search sweeps, PDF render, and Gmail send as critical-path commands. They must be observed to completion; do not assume they succeeded just because the process started.
- If a critical-path command cannot be observed to completion, stop claiming progress and report the run as incomplete.
## Isolated cron rule
- In isolated cron runs, success means all of the following are true:
- fresh search artifacts exist for the current run
- final `report-payload.json` exists for the current run
- PDF render completed
- Gmail send completed to the requested recipient
- If the model/provider times out, the VPN step flakes, or the run ends before those artifacts exist, report a failure/incomplete outcome rather than an `ok`-sounding completion summary.
- Do not end an isolated cron run on the VPN/connect phase alone.
## Search-source contract
Use the proven travel-source order from the local viability findings:
1. KAYAK
2. Skyscanner
3. Expedia
4. airline direct-booking cross-check
Rules:
- treat source viability as evidence-based, not assumed
- if a source is blocked or degraded, continue with the remaining sources and record the issue in the report
- keep the bounded search phase observable with concise updates
- normalize compared prices to USD before ranking
- do not claim market-localized pricing succeeded unless VPN connect and post-connect verification succeeded
- do not inspect prior `workspace/reports` flight artifacts as reusable search input for a new run
## VPN / market-country rules
- Use VPN only when `marketCountry` is explicitly provided in the typed request for this implementation pass.
- Connect VPN only for the bounded search phase.
- Bounded search phase means: from first travel-site navigation until the last search-result capture for the report.
- If VPN connect or verification fails within the configured timeout, continue on the default market and mark the report as degraded.
- Disconnect VPN immediately after the bounded search phase, before ranking/render/delivery.
- On rollback or failure, attempt disconnect immediately.
## Delivery rules
- Sender must be Luke: `luke@fiorinis.com`
- Delivery path must be Lukes wrapper:
- `zsh ~/.openclaw/workspace/bin/gog-luke gmail send --to "<target>" --subject "<subject>" --body "<body>" --attach "<pdf-path>"`
- If recipient email is missing, ask for it before the render/send phase.
- If the user already provided the recipient email, do not ask for a redundant “send it” confirmation.
## Helper commands
From `~/.openclaw/workspace/skills/flight-finder/` or the repo mirror copy:
```bash
npm run normalize-request -- --legacy-dfw-blq
npm run normalize-request -- --input "<request.json>"
npm run report-status -- --input "<report-payload.json>"
npm run render-report -- --input "<report-payload.json>" --output "<report.pdf>"
npm run delivery-plan -- --to "<recipient@example.com>" --subject "<subject>" --body "<body>" --attach "<report.pdf>"
```
Rules:
- `normalize-request` should report missing search inputs separately from delivery-only email gaps
- `report-status` should expose whether the run is ready to search, ready for a chat summary, ready to render a PDF, or ready to email
- `render-report` must reject incomplete report payloads
- `render-report` must reject payloads that are not explicitly marked as the output of a fresh search run
- `delivery-plan` must stay on the Luke sender path and must not silently fall back to another sender
## Prompt migration rule
The old DFW↔BLQ prompt is legacy input only. Do not treat it as freeform execution prose. Its file should contain a literal `use flight finder skill with the following json payload` request so manual runs and scheduled runs stay aligned.

View File

@@ -0,0 +1,168 @@
{
"request": {
"tripName": "DFW ↔ BLQ flight report",
"legs": [
{
"id": "outbound",
"origin": "DFW",
"destination": "BLQ",
"earliest": "2026-05-30",
"latest": "2026-06-07",
"label": "Outbound"
},
{
"id": "return-pair",
"origin": "BLQ",
"destination": "DFW",
"relativeToLegId": "outbound",
"minDaysAfter": 6,
"maxDaysAfter": 10,
"label": "Return for 2 adults"
},
{
"id": "return-solo",
"origin": "BLQ",
"destination": "DFW",
"earliest": "2026-06-28",
"latest": "2026-07-05",
"label": "Return for 1 adult"
}
],
"passengerGroups": [
{
"id": "pair",
"adults": 2,
"label": "2 adults traveling together"
},
{
"id": "solo",
"adults": 1,
"label": "1 adult returning separately"
}
],
"legAssignments": [
{
"legId": "outbound",
"passengerGroupIds": ["pair", "solo"]
},
{
"legId": "return-pair",
"passengerGroupIds": ["pair"]
},
{
"legId": "return-solo",
"passengerGroupIds": ["solo"]
}
],
"recipientEmail": "stefano@fiorinis.com",
"preferences": {
"preferOneStop": true,
"maxStops": 1,
"maxLayoverHours": 6,
"flexibleDates": true,
"excludeAirlines": ["Turkish Airlines"],
"excludeCountries": ["TR"],
"requireAirlineDirectCrossCheck": true,
"specialConstraints": [
"Exclude itineraries operated by airlines based in Arab or Middle Eastern countries.",
"Exclude itineraries routing through airports in Arab or Middle Eastern countries.",
"Start from a fresh browser/session profile for this search."
],
"geoPricingMarket": "Thailand",
"marketCountry": "TH",
"normalizeCurrencyTo": "USD"
}
},
"searchExecution": {
"freshSearch": true,
"startedAt": "2026-03-30T21:00:00Z",
"completedAt": "2026-03-30T21:20:00Z",
"artifactsRoot": "/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-exec",
"notes": [
"Fresh bounded Thailand-market search executed for this report payload.",
"Prior workspace captures must not be reused across flight-finder runs."
]
},
"sourceFindings": [
{
"source": "kayak",
"status": "viable",
"checkedAt": "2026-03-30T21:00:00Z",
"notes": [
"KAYAK returned multiple one-stop DFW -> BLQ options and exposed direct-booking hints for British Airways."
]
},
{
"source": "skyscanner",
"status": "viable",
"checkedAt": "2026-03-30T21:05:00Z",
"notes": [
"Skyscanner returned one-stop and multi-stop DFW -> BLQ results with total USD pricing."
]
},
{
"source": "expedia",
"status": "viable",
"checkedAt": "2026-03-30T21:10:00Z",
"notes": [
"Expedia returned one-way DFW -> BLQ options with clear per-traveler pricing."
]
},
{
"source": "airline-direct",
"status": "degraded",
"checkedAt": "2026-03-30T21:15:00Z",
"notes": [
"United's direct booking shell loaded with the route/date context but failed to complete the search."
]
}
],
"quotes": [
{
"id": "kayak-outbound-ba",
"source": "kayak",
"legId": "outbound",
"passengerGroupIds": ["pair", "solo"],
"bookingLink": "https://www.kayak.com/flights/DFW-BLQ/2026-05-30?adults=3&sort=bestflight_a&fs=stops=-2",
"itinerarySummary": "British Airways via London Heathrow",
"airlineName": "British Airways",
"departureTimeLocal": "10:59 PM",
"arrivalTimeLocal": "11:45 PM +1",
"stopsText": "1 stop",
"layoverText": "6h 15m in London Heathrow",
"totalDurationText": "17h 46m",
"totalPriceUsd": 2631,
"displayPriceUsd": "$2,631 total",
"directBookingUrl": "https://www.britishairways.com/",
"crossCheckStatus": "failed",
"notes": [
"Observed on KAYAK at $877 per traveler for 3 adults.",
"Airline-direct cross-check remains degraded in this implementation pass."
]
}
],
"rankedOptions": [
{
"id": "primary-outbound",
"title": "Best observed outbound baseline",
"quoteIds": ["kayak-outbound-ba"],
"totalPriceUsd": 2631,
"rationale": "Lowest observed one-stop fare in the bounded spike while still meeting the layover constraint."
}
],
"executiveSummary": [
"The bounded implementation-pass smoke test produced a viable report payload from real DFW -> BLQ search evidence.",
"KAYAK, Skyscanner, and Expedia all returned usable results on this machine.",
"Direct-airline cross-checking is currently best-effort and should be reported honestly when it fails."
],
"reportWarnings": [
"This smoke-test payload captures the bounded implementation-pass workflow, not the full old prompt's multi-leg optimization."
],
"degradedReasons": [
"Airline direct-booking cross-check is still degraded and must be treated as best-effort in this pass."
],
"comparisonCurrency": "USD",
"marketCountryUsed": "TH",
"lastCompletedPhase": "ranking",
"generatedAt": "2026-03-30T21:20:00Z"
}

View File

@@ -0,0 +1,74 @@
{
"tripName": "DFW ↔ BLQ flight report",
"legs": [
{
"id": "outbound",
"origin": "DFW",
"destination": "BLQ",
"earliest": "2026-05-30",
"latest": "2026-06-07",
"label": "Outbound"
},
{
"id": "return-pair",
"origin": "BLQ",
"destination": "DFW",
"relativeToLegId": "outbound",
"minDaysAfter": 6,
"maxDaysAfter": 10,
"label": "Return for 2 adults"
},
{
"id": "return-solo",
"origin": "BLQ",
"destination": "DFW",
"earliest": "2026-06-28",
"latest": "2026-07-05",
"label": "Return for 1 adult"
}
],
"passengerGroups": [
{
"id": "pair",
"adults": 2,
"label": "2 adults traveling together"
},
{
"id": "solo",
"adults": 1,
"label": "1 adult returning separately"
}
],
"legAssignments": [
{
"legId": "outbound",
"passengerGroupIds": ["pair", "solo"]
},
{
"legId": "return-pair",
"passengerGroupIds": ["pair"]
},
{
"legId": "return-solo",
"passengerGroupIds": ["solo"]
}
],
"recipientEmail": "stefano@fiorinis.com",
"preferences": {
"preferOneStop": true,
"maxStops": 1,
"maxLayoverHours": 6,
"flexibleDates": true,
"excludeAirlines": ["Turkish Airlines"],
"excludeCountries": ["TR"],
"requireAirlineDirectCrossCheck": true,
"specialConstraints": [
"Exclude itineraries operated by airlines based in Arab or Middle Eastern countries.",
"Exclude itineraries routing through airports in Arab or Middle Eastern countries.",
"Start from a fresh browser/session profile for this search."
],
"geoPricingMarket": "Thailand",
"marketCountry": "TH",
"normalizeCurrencyTo": "USD"
}
}

791
skills/flight-finder/package-lock.json generated Normal file
View File

@@ -0,0 +1,791 @@
{
"name": "flight-finder-scripts",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flight-finder-scripts",
"version": "1.0.0",
"dependencies": {
"minimist": "^1.2.8",
"pdfkit": "^0.17.2"
},
"devDependencies": {
"@types/minimist": "^1.2.5",
"@types/pdfkit": "^0.17.3",
"tsx": "^4.20.6",
"typescript": "^5.9.2"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.20",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.20.tgz",
"integrity": "sha512-2egEBHUMasdypIzrprsu8g+OEVd7Vp2MM3a2eVlM/cyFYto0nGz5BX5BTgh/ShZZI9ed+ozEq+Ngt+rgmUs8tw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@types/minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/pdfkit": {
"version": "0.17.5",
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.5.tgz",
"integrity": "sha512-T3ZHnvF91HsEco5ClhBCOuBwobZfPcI2jaiSHybkkKYq4KhVIIurod94JVKvDIG0JXT6o3KiERC0X0//m8dyrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.4",
"@esbuild/android-arm": "0.27.4",
"@esbuild/android-arm64": "0.27.4",
"@esbuild/android-x64": "0.27.4",
"@esbuild/darwin-arm64": "0.27.4",
"@esbuild/darwin-x64": "0.27.4",
"@esbuild/freebsd-arm64": "0.27.4",
"@esbuild/freebsd-x64": "0.27.4",
"@esbuild/linux-arm": "0.27.4",
"@esbuild/linux-arm64": "0.27.4",
"@esbuild/linux-ia32": "0.27.4",
"@esbuild/linux-loong64": "0.27.4",
"@esbuild/linux-mips64el": "0.27.4",
"@esbuild/linux-ppc64": "0.27.4",
"@esbuild/linux-riscv64": "0.27.4",
"@esbuild/linux-s390x": "0.27.4",
"@esbuild/linux-x64": "0.27.4",
"@esbuild/netbsd-arm64": "0.27.4",
"@esbuild/netbsd-x64": "0.27.4",
"@esbuild/openbsd-arm64": "0.27.4",
"@esbuild/openbsd-x64": "0.27.4",
"@esbuild/openharmony-arm64": "0.27.4",
"@esbuild/sunos-x64": "0.27.4",
"@esbuild/win32-arm64": "0.27.4",
"@esbuild/win32-ia32": "0.27.4",
"@esbuild/win32-x64": "0.27.4"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/jpeg-exif": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/pdfkit": {
"version": "0.17.2",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
"license": "MIT",
"dependencies": {
"crypto-js": "^4.2.0",
"fontkit": "^2.0.4",
"jpeg-exif": "^1.1.4",
"linebreak": "^1.1.0",
"png-js": "^1.0.0"
}
},
"node_modules/png-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
"integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
}
}
}

View File

@@ -0,0 +1,23 @@
{
"name": "flight-finder-scripts",
"version": "1.0.0",
"description": "Flight finder helpers for request normalization, readiness gating, and fixed-template PDF rendering",
"type": "module",
"scripts": {
"normalize-request": "tsx src/cli.ts normalize-request",
"report-status": "tsx src/cli.ts report-status",
"render-report": "tsx src/cli.ts render-report",
"delivery-plan": "tsx src/cli.ts delivery-plan",
"test": "node --import tsx --test tests/*.test.ts"
},
"dependencies": {
"minimist": "^1.2.8",
"pdfkit": "^0.17.2"
},
"devDependencies": {
"@types/minimist": "^1.2.5",
"@types/pdfkit": "^0.17.3",
"tsx": "^4.20.6",
"typescript": "^5.9.2"
}
}

View File

@@ -0,0 +1,75 @@
# Flight Finder Source Viability
This note records bounded live checks for the first implementation pass.
It is intentionally operational, not aspirational: if a source is blocked here, the skill must treat it as blocked unless later evidence replaces this note.
## Required sources
- KAYAK
- Skyscanner
- Expedia
- airline direct-booking cross-check
## Status
Checked on `2026-03-30` from Stefano's MacBook Air, with no VPN active, using the existing local `web-automation` / CloakBrowser probe scripts.
Route used for bounded checks:
- `DFW -> BLQ`
- outbound date: `2026-05-30`
- travelers: `3 adults`
### KAYAK
- Status: `viable`
- Probe: `node flight_kayak_sweep.mjs DFW BLQ 3 /tmp/flight-finder-kayak-dates.json ...`
- Evidence:
- title returned as `DFW to BLQ, 5/30`
- results included multiple one-stop itineraries with parsed USD fares such as `$877`, `$949`, `$955`
- direct-booking hints were visible for British Airways on at least some results
- Implementation note:
- KAYAK can be a primary source in this first implementation pass
- parsed text is workable, but still brittle enough that bounded retries and status fallback remain necessary
### Skyscanner
- Status: `viable`
- Probe: `node tmp_skyscanner_probe.mjs 'https://www.skyscanner.com/transport/flights/dfw/blq/260530/?adultsv2=3&cabinclass=economy&rtn=0'`
- Evidence:
- title returned as `Cheap flights from Dallas to Bologna on Skyscanner`
- results page exposed concrete prices, total trip prices, stops, and itinerary text
- one-stop and multi-stop options were visible in the captured text
- Implementation note:
- Skyscanner is viable for bounded result capture in this first pass
- itinerary extraction should still be treated as text-scrape, not a stable API
### Expedia
- Status: `viable`
- Probe: `node tmp_expedia_probe.mjs 'https://www.expedia.com/Flights-Search?...'`
- Evidence:
- title returned as `DFW to BLQ flights`
- results page exposed current lowest price, airline/stops filters, and concrete per-traveler options such as `$877`, `$949`, `$961`
- Expedia text already surfaced some itinerary summaries in a report-friendly format
- Implementation note:
- Expedia is viable for bounded result capture in this first pass
- as with the other aggregators, source-specific timeouts and fallback rules are still required
### Airline direct-booking cross-check
- Status: `degraded`
- Probe: `node tmp_skyscanner_probe.mjs 'https://www.united.com/en/us/fsr/choose-flights?...'`
- Evidence:
- United's booking shell loaded and recognized the route / date context
- the search then returned `united.com was unable to complete your request. Please try again later.`
- Implementation note:
- direct-airline cross-checking remains in scope, but it should be treated as best-effort in the first pass
- when a direct site fails or refuses completion, the skill should record the failure explicitly instead of hanging or pretending a clean cross-check happened
## Scope decision for implementation pass 1
- Primary bounded search sources: `KAYAK`, `Skyscanner`, `Expedia`
- Direct-airline cross-check: `best-effort / degraded`
- The skill should continue if the direct-airline step fails, but the report must say that the direct cross-check was not fully completed

View File

@@ -0,0 +1,86 @@
import fs from "node:fs/promises";
import minimist from "minimist";
import {
DFW_BLQ_2026_PROMPT_DRAFT,
normalizeFlightReportRequest
} from "./request-normalizer.js";
import { getFlightReportStatus } from "./report-status.js";
import { renderFlightReportPdf } from "./report-pdf.js";
import { buildLukeDeliveryPlan } from "./report-delivery.js";
import type { FlightReportPayload, FlightReportRequestDraft } from "./types.js";
function usage(): never {
throw new Error(`Usage:
flight-finder normalize-request --input "<request.json>"
flight-finder normalize-request --legacy-dfw-blq
flight-finder report-status --input "<report-payload.json>"
flight-finder render-report --input "<report-payload.json>" --output "<report.pdf>"
flight-finder delivery-plan --to "<recipient@example.com>" --subject "<subject>" --body "<body>" --attach "<report.pdf>"`);
}
async function readJsonFile<T>(filePath: string): Promise<T> {
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
}
async function main(): Promise<void> {
const argv = minimist(process.argv.slice(2), {
string: ["input", "output", "to", "subject", "body", "attach"],
boolean: ["legacy-dfw-blq"]
});
const command = argv._[0];
if (!command) {
usage();
}
if (command === "normalize-request") {
const draft = argv["legacy-dfw-blq"]
? DFW_BLQ_2026_PROMPT_DRAFT
: await readJsonFile<FlightReportRequestDraft>(argv.input || usage());
console.log(JSON.stringify(normalizeFlightReportRequest(draft), null, 2));
return;
}
if (command === "report-status") {
const payload = await readJsonFile<FlightReportPayload>(argv.input || usage());
console.log(
JSON.stringify(
getFlightReportStatus(normalizeFlightReportRequest(payload.request), payload),
null,
2
)
);
return;
}
if (command === "render-report") {
const payload = await readJsonFile<FlightReportPayload>(argv.input || usage());
const rendered = await renderFlightReportPdf(payload, argv.output || usage());
console.log(rendered);
return;
}
if (command === "delivery-plan") {
console.log(
JSON.stringify(
buildLukeDeliveryPlan({
recipientEmail: argv.to,
subject: argv.subject || "",
body: argv.body || "",
attachmentPath: argv.attach || usage()
}),
null,
2
)
);
return;
}
usage();
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});

View File

@@ -0,0 +1,25 @@
export function normalizeMarketCountry(
value: string | null | undefined
): string | null | undefined {
const cleaned = typeof value === "string" ? value.trim() : undefined;
if (!cleaned) {
return undefined;
}
const normalized = cleaned.toUpperCase();
if (!/^[A-Z]{2}$/.test(normalized)) {
throw new Error(
`Invalid marketCountry "${value}". Use an ISO 3166-1 alpha-2 uppercase country code such as "TH" or "DE".`
);
}
return normalized;
}
export function isPlausibleEmail(value: string | null | undefined): boolean {
if (!value) {
return false;
}
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}

View File

@@ -0,0 +1,36 @@
export type NormalizePriceInput = {
amount: number;
currency: string;
exchangeRates?: Record<string, number>;
};
export type NormalizedPrice = {
currency: "USD";
amountUsd: number;
notes: string[];
};
export function normalizePriceToUsd(
input: NormalizePriceInput
): NormalizedPrice {
const currency = input.currency.trim().toUpperCase();
if (currency === "USD") {
return {
currency: "USD",
amountUsd: input.amount,
notes: ["Price already in USD."]
};
}
const rate = input.exchangeRates?.[currency];
if (typeof rate !== "number" || !Number.isFinite(rate) || rate <= 0) {
throw new Error(`Missing usable exchange rate for ${currency}.`);
}
const amountUsd = Math.round(input.amount * rate * 100) / 100;
return {
currency: "USD",
amountUsd,
notes: [`Converted ${currency} to USD using rate ${rate}.`]
};
}

View File

@@ -0,0 +1,50 @@
import { isPlausibleEmail } from "./input-validation.js";
type DeliveryPlanInput = {
recipientEmail?: string | null;
subject: string;
body: string;
attachmentPath: string;
};
export type DeliveryPlan = {
ready: boolean;
needsRecipientEmail: boolean;
command: string | null;
sender: "luke@fiorinis.com";
recipientEmail: string | null;
};
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
}
export function buildLukeDeliveryPlan(input: DeliveryPlanInput): DeliveryPlan {
const recipientEmail = input.recipientEmail?.trim() || null;
if (!recipientEmail || !isPlausibleEmail(recipientEmail)) {
return {
ready: false,
needsRecipientEmail: true,
command: null,
sender: "luke@fiorinis.com",
recipientEmail
};
}
const validRecipientEmail: string = recipientEmail;
const command = [
"zsh ~/.openclaw/workspace/bin/gog-luke gmail send",
`--to ${shellQuote(validRecipientEmail)}`,
`--subject ${shellQuote(input.subject)}`,
`--body ${shellQuote(input.body)}`,
`--attach ${shellQuote(input.attachmentPath)}`
].join(" ");
return {
ready: true,
needsRecipientEmail: false,
command,
sender: "luke@fiorinis.com",
recipientEmail: validRecipientEmail
};
}

View File

@@ -0,0 +1,407 @@
import fs from "node:fs";
import path from "node:path";
import PDFDocument from "pdfkit";
import { getFlightReportStatus } from "./report-status.js";
import { normalizeFlightReportRequest } from "./request-normalizer.js";
import type {
FlightLegWindow,
FlightPassengerGroup,
FlightQuote,
FlightRecommendationOption,
FlightReportPayload
} from "./types.js";
export class ReportValidationError extends Error {}
function bulletLines(value: string[] | undefined, fallback = "Not provided."): string[] {
return value?.length ? value : [fallback];
}
function asLegLabelMap(legs: FlightLegWindow[]): Map<string, string> {
return new Map(legs.map((leg) => [leg.id, leg.label || `${leg.origin} -> ${leg.destination}`]));
}
function asPassengerGroupLabelMap(groups: FlightPassengerGroup[]): Map<string, string> {
return new Map(
groups.map((group) => [group.id, group.label || `${group.adults} adult${group.adults === 1 ? "" : "s"}`])
);
}
function formatUsd(value: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0
}).format(value);
}
function formatDateWindow(leg: FlightLegWindow): string {
if (leg.earliest && leg.latest) {
return leg.earliest === leg.latest ? leg.earliest : `${leg.earliest} to ${leg.latest}`;
}
if (leg.relativeToLegId) {
const min = leg.minDaysAfter ?? "?";
const max = leg.maxDaysAfter ?? "?";
return `${min}-${max} days after ${leg.relativeToLegId}`;
}
return "Not provided";
}
function ensurePageSpace(doc: PDFKit.PDFDocument, requiredHeight: number): void {
const bottomLimit = doc.page.height - doc.page.margins.bottom;
if (doc.y + requiredHeight > bottomLimit) {
doc.addPage();
}
}
const WORKSPACE_REPORTS_PREFIX = "/Users/stefano/.openclaw/workspace/reports/";
export function formatArtifactsRoot(value: string): string {
const trimmed = String(value || "").trim();
if (!trimmed) return "Not provided";
if (trimmed.startsWith(WORKSPACE_REPORTS_PREFIX)) {
return `reports/${trimmed.slice(WORKSPACE_REPORTS_PREFIX.length)}`;
}
return path.basename(trimmed) || trimmed;
}
function drawSectionHeader(doc: PDFKit.PDFDocument, title: string): void {
ensurePageSpace(doc, 28);
const x = doc.page.margins.left;
const y = doc.y;
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const height = 18;
doc.save();
doc.roundedRect(x, y, width, height, 3).fill("#10384E");
doc
.fillColor("white")
.font("Helvetica-Bold")
.fontSize(12)
.text(title, x + 8, y + 4, { width: width - 16 });
doc.restore();
doc.moveDown(1.2);
}
function drawBulletList(doc: PDFKit.PDFDocument, items: string[] | undefined, fallback = "Not provided."): void {
const lines = bulletLines(items, fallback);
for (const line of lines) {
const lineHeight = doc.heightOfString(line, {
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
lineGap: 2
});
ensurePageSpace(doc, lineHeight + 12);
const startY = doc.y;
doc.circle(doc.page.margins.left + 4, startY + 6, 1.5).fill("#10384E");
doc
.fillColor("#1E2329")
.font("Helvetica")
.fontSize(10.5)
.text(line, doc.page.margins.left + 14, startY, {
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
lineGap: 2
});
doc.moveDown(0.35);
}
doc.moveDown(0.2);
}
type QuoteBookingLink = {
label: string;
text: string;
url: string | null;
};
export function describeQuoteBookingLinks(quote: FlightQuote): QuoteBookingLink[] {
return [
{
label: "Search / book this fare",
text: `Search / book this fare: ${quote.displayPriceUsd} via ${quote.source}`,
url: quote.bookingLink
},
{
label: "Direct airline booking",
text: quote.directBookingUrl
? `Direct airline booking: ${quote.airlineName || "Airline site"}`
: "Direct airline booking: not captured in this run",
url: quote.directBookingUrl || null
}
];
}
function drawQuoteBookingLinks(doc: PDFKit.PDFDocument, links: QuoteBookingLink[], left: number, width: number): void {
for (const linkItem of links) {
const lineHeight = Math.max(
doc.heightOfString(`${linkItem.label}:`, { width: 130 }),
doc.heightOfString(linkItem.text.replace(`${linkItem.label}: `, ""), {
width: width - 132,
lineGap: 1
})
);
ensurePageSpace(doc, lineHeight + 8);
const startY = doc.y;
doc
.fillColor("#1E2329")
.font("Helvetica-Bold")
.fontSize(9.5)
.text(`${linkItem.label}:`, left, startY, { width: 130 });
doc
.fillColor(linkItem.url ? "#0B5EA8" : "#4F5B66")
.font("Helvetica")
.fontSize(9.5)
.text(linkItem.text.replace(`${linkItem.label}: `, ""), left + 132, startY, {
width: width - 132,
lineGap: 1,
...(linkItem.url ? { link: linkItem.url, underline: true } : {})
});
doc.moveDown(0.25);
}
}
function drawKeyValueTable(doc: PDFKit.PDFDocument, rows: Array<[string, string]>): void {
const left = doc.page.margins.left;
const totalWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const keyWidth = 170;
const valueWidth = totalWidth - keyWidth;
for (const [key, value] of rows) {
const keyHeight = doc.heightOfString(key, { width: keyWidth - 12 });
const valueHeight = doc.heightOfString(value, { width: valueWidth - 12 });
const rowHeight = Math.max(keyHeight, valueHeight) + 12;
ensurePageSpace(doc, rowHeight + 8);
const startY = doc.y;
doc.save();
doc.rect(left, startY, keyWidth, rowHeight).fill("#EEF3F7");
doc.rect(left + keyWidth, startY, valueWidth, rowHeight).fill("#FFFFFF");
doc
.lineWidth(0.5)
.strokeColor("#C7D0D9")
.rect(left, startY, totalWidth, rowHeight)
.stroke();
doc.moveTo(left + keyWidth, startY).lineTo(left + keyWidth, startY + rowHeight).stroke();
doc.restore();
doc
.fillColor("#1E2329")
.font("Helvetica-Bold")
.fontSize(9.5)
.text(key, left + 6, startY + 6, { width: keyWidth - 12 });
doc
.fillColor("#1E2329")
.font("Helvetica")
.fontSize(9.5)
.text(value, left + keyWidth + 6, startY + 6, { width: valueWidth - 12 });
doc.y = startY + rowHeight;
}
doc.moveDown(0.8);
}
function ensureFreshSearch(payload: FlightReportPayload): void {
const searchExecution = payload.searchExecution;
if (!searchExecution?.freshSearch) {
throw new ReportValidationError(
"The flight report payload must come from a fresh search run. Cached or previously captured results are not allowed."
);
}
const startedAt = Date.parse(searchExecution.startedAt);
const completedAt = Date.parse(searchExecution.completedAt);
if (!Number.isFinite(startedAt) || !Number.isFinite(completedAt) || completedAt < startedAt) {
throw new ReportValidationError(
"The flight report payload must include a valid fresh-search time window."
);
}
if (!String(searchExecution.artifactsRoot || "").trim()) {
throw new ReportValidationError(
"The flight report payload must include the artifacts root for the fresh search run."
);
}
}
function drawItineraryCard(
doc: PDFKit.PDFDocument,
option: FlightRecommendationOption,
quotesById: Map<string, FlightQuote>,
legLabels: Map<string, string>,
passengerLabels: Map<string, string>
): void {
const left = doc.page.margins.left;
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const badge = option.backup ? "Backup option" : "Primary recommendation";
const quoteBlocks = option.quoteIds.flatMap((quoteId) => {
const quote = quotesById.get(quoteId);
if (!quote) {
return [
{
heading: `Missing quote reference: ${quoteId}`,
details: [],
links: []
}
];
}
const passengerLine = quote.passengerGroupIds
.map((groupId) => passengerLabels.get(groupId) || groupId)
.join(", ");
const legLine = legLabels.get(quote.legId) || quote.legId;
return [
{
heading: `${legLine}: ${quote.itinerarySummary} (${quote.displayPriceUsd})`,
details: [
`Passengers: ${passengerLine}`,
`Timing: ${quote.departureTimeLocal || "?"} -> ${quote.arrivalTimeLocal || "?"}`,
`Routing: ${quote.stopsText || "stops?"}; ${quote.layoverText || "layover?"}; ${quote.totalDurationText || "duration?"}`,
...(quote.notes || []).map((note) => `Note: ${note}`)
],
links: describeQuoteBookingLinks(quote)
}
];
});
ensurePageSpace(doc, 28);
const top = doc.y;
doc.save();
doc.roundedRect(left, top, width, 22, 4).fill(option.backup ? "#586E75" : "#1E6B52");
doc
.fillColor("white")
.font("Helvetica-Bold")
.fontSize(10.5)
.text(`${badge}: ${option.title}`, left + 10, top + 6.5, { width: width - 20 });
doc.restore();
doc.y = top + 28;
drawKeyValueTable(doc, [
["Recommendation total", formatUsd(option.totalPriceUsd)],
["Why it ranked here", option.rationale]
]);
for (const block of quoteBlocks) {
ensurePageSpace(doc, 22);
doc
.fillColor("#10384E")
.font("Helvetica-Bold")
.fontSize(10)
.text(block.heading, left, doc.y, {
width,
lineGap: 2
});
doc.moveDown(0.2);
drawBulletList(doc, block.details);
drawQuoteBookingLinks(doc, block.links, left, width);
doc.moveDown(0.5);
}
doc.moveDown(0.2);
}
export async function renderFlightReportPdf(
payload: FlightReportPayload,
outputPath: string
): Promise<string> {
const normalizedRequest = normalizeFlightReportRequest(payload.request);
const status = getFlightReportStatus(normalizedRequest, payload);
if (!status.pdfReady) {
throw new ReportValidationError(
"The flight report payload is still incomplete. Finish the report before generating the PDF."
);
}
ensureFreshSearch(payload);
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
const doc = new PDFDocument({
size: "LETTER",
margin: 50,
info: {
Title: payload.request.tripName || "Flight report",
Author: "OpenClaw flight-finder"
}
});
const stream = fs.createWriteStream(outputPath);
doc.pipe(stream);
const request = normalizedRequest.request;
const legLabels = asLegLabelMap(request.legs);
const passengerLabels = asPassengerGroupLabelMap(request.passengerGroups);
const quotesById = new Map(payload.quotes.map((quote) => [quote.id, quote]));
try {
doc.fillColor("#10384E").font("Helvetica-Bold").fontSize(22).text(request.tripName || "Flight report");
doc.moveDown(0.2);
doc
.fillColor("#4F5B66")
.font("Helvetica")
.fontSize(10)
.text(`Generated: ${payload.generatedAt}`);
doc
.fillColor("#4F5B66")
.font("Helvetica")
.fontSize(10)
.text(
`Market: ${payload.marketCountryUsed || "Default market"} | Fresh search completed: ${payload.searchExecution.completedAt}`
);
doc.moveDown();
drawSectionHeader(doc, "Trip Snapshot");
drawKeyValueTable(doc, [
["Recipient", request.recipientEmail || "Missing"],
["Passenger groups", request.passengerGroups.map((group) => group.label || `${group.adults} adults`).join(" | ")],
["Preferences", [
request.preferences.flexibleDates ? "Flexible dates" : "Fixed dates",
request.preferences.maxStops != null ? `Max stops ${request.preferences.maxStops}` : "Stops not capped",
request.preferences.maxLayoverHours != null ? `Layover <= ${request.preferences.maxLayoverHours}h` : "Layover open"
].join(" | ")],
["Market-country mode", request.preferences.marketCountry || "Off"],
["Search artifacts", formatArtifactsRoot(payload.searchExecution.artifactsRoot)]
]);
drawSectionHeader(doc, "Legs And Travelers");
for (const leg of request.legs) {
const assignedGroups = request.legAssignments
.find((assignment) => assignment.legId === leg.id)
?.passengerGroupIds.map((groupId) => passengerLabels.get(groupId) || groupId)
.join(", ") || "Not assigned";
drawKeyValueTable(doc, [
["Leg", leg.label || leg.id],
["Route", `${leg.origin} -> ${leg.destination}`],
["Window", formatDateWindow(leg)],
["Travelers", assignedGroups]
]);
}
drawSectionHeader(doc, "Executive Summary");
drawBulletList(doc, payload.executiveSummary);
drawSectionHeader(doc, "Recommended Options");
payload.rankedOptions.forEach((option) => drawItineraryCard(doc, option, quotesById, legLabels, passengerLabels));
drawSectionHeader(doc, "Source Findings");
payload.sourceFindings.forEach((finding) => {
drawKeyValueTable(doc, [
["Source", finding.source],
["Status", finding.status],
["Checked", finding.checkedAt]
]);
drawBulletList(doc, finding.notes, "No source notes.");
});
drawSectionHeader(doc, "Warnings And Degraded Conditions");
drawBulletList(doc, [...payload.reportWarnings, ...payload.degradedReasons], "No additional warnings.");
doc.end();
await new Promise<void>((resolve, reject) => {
stream.on("finish", () => resolve());
stream.on("error", reject);
});
} catch (error) {
stream.destroy();
await fs.promises.unlink(outputPath).catch(() => {});
throw error;
}
return outputPath;
}

View File

@@ -0,0 +1,83 @@
import type {
FlightReportPayload,
FlightReportStatusResult,
NormalizedFlightReportRequest
} from "./types.js";
import { isPlausibleEmail } from "./input-validation.js";
export function getFlightReportStatus(
normalizedRequest: NormalizedFlightReportRequest,
payload?: FlightReportPayload | null
): FlightReportStatusResult {
if (normalizedRequest.missingSearchInputs.length) {
return {
needsMissingInputs: normalizedRequest.missingSearchInputs,
readyToSearch: false,
pdfReady: false,
emailReady: false,
chatSummaryReady: false,
terminalOutcome: "missing-inputs",
degraded: false,
degradedReasons: [],
blockingReason: "Missing search-critical trip inputs."
};
}
if (!payload) {
return {
needsMissingInputs: normalizedRequest.missingDeliveryInputs,
readyToSearch: true,
pdfReady: false,
emailReady: false,
chatSummaryReady: false,
terminalOutcome: "report-incomplete",
degraded: false,
degradedReasons: [],
lastCompletedPhase: "intake",
blockingReason: "Search and report assembly have not completed yet."
};
}
const allSourcesFailed =
payload.sourceFindings.length > 0 &&
payload.sourceFindings.every((finding) => finding.status === "blocked") &&
payload.quotes.length === 0;
if (allSourcesFailed) {
return {
needsMissingInputs: normalizedRequest.missingDeliveryInputs,
readyToSearch: true,
pdfReady: false,
emailReady: false,
chatSummaryReady: true,
terminalOutcome: "all-sources-failed",
degraded: true,
degradedReasons: payload.degradedReasons,
lastCompletedPhase: payload.lastCompletedPhase || "search",
blockingReason: "All configured travel sources failed or were blocked."
};
}
const reportComplete = Boolean(
payload.quotes.length &&
payload.rankedOptions.length &&
payload.executiveSummary.length
);
const validRecipient = isPlausibleEmail(normalizedRequest.request.recipientEmail);
return {
needsMissingInputs: validRecipient ? [] : normalizedRequest.missingDeliveryInputs,
readyToSearch: true,
pdfReady: reportComplete,
emailReady: reportComplete && validRecipient,
chatSummaryReady:
payload.quotes.length > 0 ||
payload.rankedOptions.length > 0 ||
payload.executiveSummary.length > 0,
terminalOutcome: reportComplete ? "ready" : "report-incomplete",
degraded: payload.degradedReasons.length > 0,
degradedReasons: payload.degradedReasons,
lastCompletedPhase: payload.lastCompletedPhase,
blockingReason: reportComplete ? undefined : "The report payload is still incomplete."
};
}

View File

@@ -0,0 +1,309 @@
import type {
FlightLegWindow,
FlightPassengerGroup,
FlightReportRequest,
FlightReportRequestDraft,
FlightSearchPreferences,
NormalizedFlightReportRequest
} from "./types.js";
import { isPlausibleEmail, normalizeMarketCountry } from "./input-validation.js";
const DEFAULT_PREFERENCES: FlightSearchPreferences = {
preferOneStop: true,
maxLayoverHours: 6,
normalizeCurrencyTo: "USD"
};
function cleanString(value: string | null | undefined): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function uniqueStrings(values: Array<string | null | undefined> | undefined): string[] | undefined {
if (!values?.length) {
return undefined;
}
const cleaned = values
.map((value) => cleanString(value))
.filter((value): value is string => Boolean(value));
return cleaned.length ? Array.from(new Set(cleaned)) : undefined;
}
function normalizePassengerGroups(
groups: FlightReportRequestDraft["passengerGroups"]
): FlightPassengerGroup[] {
return (groups || [])
.filter((group): group is NonNullable<typeof group> => Boolean(group))
.map((group, index) => ({
id: cleanString(group.id) || `group-${index + 1}`,
adults: Number(group.adults || 0),
children: Number(group.children || 0) || undefined,
infants: Number(group.infants || 0) || undefined,
label: cleanString(group.label)
}));
}
function normalizeLegs(legs: FlightReportRequestDraft["legs"]): FlightLegWindow[] {
return (legs || [])
.filter((leg): leg is NonNullable<typeof leg> => Boolean(leg))
.map((leg, index) => ({
id: cleanString(leg.id) || `leg-${index + 1}`,
origin: cleanString(leg.origin) || "",
destination: cleanString(leg.destination) || "",
earliest: cleanString(leg.earliest),
latest: cleanString(leg.latest),
relativeToLegId: cleanString(leg.relativeToLegId),
minDaysAfter:
typeof leg.minDaysAfter === "number" && Number.isFinite(leg.minDaysAfter)
? leg.minDaysAfter
: undefined,
maxDaysAfter:
typeof leg.maxDaysAfter === "number" && Number.isFinite(leg.maxDaysAfter)
? leg.maxDaysAfter
: undefined,
label: cleanString(leg.label)
}));
}
function normalizeAssignments(
draft: FlightReportRequestDraft["legAssignments"]
): FlightReportRequest["legAssignments"] {
return (draft || [])
.filter((assignment): assignment is NonNullable<typeof assignment> => Boolean(assignment))
.map((assignment) => ({
legId: cleanString(assignment.legId) || "",
passengerGroupIds:
uniqueStrings(
(assignment.passengerGroupIds || []).map((value) =>
typeof value === "string" ? value : undefined
)
) || []
}));
}
function normalizePreferences(
preferences: FlightReportRequestDraft["preferences"]
): FlightSearchPreferences {
const draft = preferences || {};
return {
...DEFAULT_PREFERENCES,
cabin: draft.cabin,
maxStops: typeof draft.maxStops === "number" ? draft.maxStops : undefined,
preferOneStop:
typeof draft.preferOneStop === "boolean"
? draft.preferOneStop
: DEFAULT_PREFERENCES.preferOneStop,
maxLayoverHours:
typeof draft.maxLayoverHours === "number"
? draft.maxLayoverHours
: DEFAULT_PREFERENCES.maxLayoverHours,
excludeAirlines: uniqueStrings(draft.excludeAirlines),
excludeCountries: uniqueStrings(draft.excludeCountries),
excludeAirports: uniqueStrings(draft.excludeAirports),
flexibleDates:
typeof draft.flexibleDates === "boolean" ? draft.flexibleDates : undefined,
requireAirlineDirectCrossCheck:
typeof draft.requireAirlineDirectCrossCheck === "boolean"
? draft.requireAirlineDirectCrossCheck
: undefined,
specialConstraints: uniqueStrings(draft.specialConstraints),
geoPricingMarket: cleanString(draft.geoPricingMarket),
marketCountry: normalizeMarketCountry(draft.marketCountry),
normalizeCurrencyTo: "USD"
};
}
function collectMissingSearchInputs(request: FlightReportRequest): string[] {
const missing = new Set<string>();
if (!request.legs.length) {
missing.add("trip legs");
}
if (!request.passengerGroups.length) {
missing.add("passenger groups");
}
for (const group of request.passengerGroups) {
const total = group.adults + (group.children || 0) + (group.infants || 0);
if (total <= 0) {
missing.add(`traveler count for passenger group ${group.label || group.id}`);
}
}
const legIds = new Set(request.legs.map((leg) => leg.id));
const groupIds = new Set(request.passengerGroups.map((group) => group.id));
for (const leg of request.legs) {
const label = leg.label || leg.id;
if (!leg.origin || !leg.destination) {
missing.add(`origin and destination for ${label}`);
}
const hasAbsoluteWindow = Boolean(leg.earliest || leg.latest);
const hasRelativeWindow = Boolean(
leg.relativeToLegId &&
(typeof leg.minDaysAfter === "number" || typeof leg.maxDaysAfter === "number")
);
if (!hasAbsoluteWindow && !hasRelativeWindow) {
missing.add(`date window for ${label}`);
}
if (leg.relativeToLegId && !legIds.has(leg.relativeToLegId)) {
missing.add(`valid reference leg for ${label}`);
}
}
if (!request.legAssignments.length) {
missing.add("passenger assignments for every leg");
}
for (const leg of request.legs) {
const label = leg.label || leg.id;
const assignment = request.legAssignments.find((entry) => entry.legId === leg.id);
if (!assignment) {
missing.add(`passenger assignments for ${label}`);
continue;
}
if (!assignment.passengerGroupIds.length) {
missing.add(`assigned travelers for ${label}`);
continue;
}
if (assignment.passengerGroupIds.some((groupId) => !groupIds.has(groupId))) {
missing.add(`valid passenger-group references for ${label}`);
}
}
return Array.from(missing);
}
export function normalizeFlightReportRequest(
draft: FlightReportRequestDraft
): NormalizedFlightReportRequest {
const normalizedRecipientEmail = cleanString(draft.recipientEmail) || null;
const request: FlightReportRequest = {
tripName: cleanString(draft.tripName),
legs: normalizeLegs(draft.legs),
passengerGroups: normalizePassengerGroups(draft.passengerGroups),
legAssignments: normalizeAssignments(draft.legAssignments),
recipientEmail: normalizedRecipientEmail,
preferences: normalizePreferences(draft.preferences)
};
const missingSearchInputs = collectMissingSearchInputs(request);
const missingDeliveryInputs = !request.recipientEmail
? ["recipient email"]
: isPlausibleEmail(request.recipientEmail)
? []
: ["valid recipient email"];
const warnings: string[] = [];
if (!request.recipientEmail) {
warnings.push(
"Recipient email is still missing. Ask for it before rendering or sending the PDF report."
);
} else if (!isPlausibleEmail(request.recipientEmail)) {
warnings.push(
"Recipient email looks malformed. Ask for a corrected email address before rendering or sending the PDF report."
);
}
if (request.preferences.marketCountry && !request.preferences.geoPricingMarket) {
warnings.push(
`Market-localized search is explicit for this run. Connect VPN to ${request.preferences.marketCountry} only for the bounded search phase, then disconnect before ranking/render/delivery.`
);
}
return {
request,
readyToSearch: missingSearchInputs.length === 0,
missingInputs: [...missingSearchInputs, ...missingDeliveryInputs],
missingSearchInputs,
missingDeliveryInputs,
warnings
};
}
export const DFW_BLQ_2026_PROMPT_DRAFT: FlightReportRequestDraft = {
tripName: "DFW ↔ BLQ flight report",
legs: [
{
id: "outbound",
origin: "DFW",
destination: "BLQ",
earliest: "2026-05-30",
latest: "2026-06-07",
label: "Outbound"
},
{
id: "return-pair",
origin: "BLQ",
destination: "DFW",
relativeToLegId: "outbound",
minDaysAfter: 6,
maxDaysAfter: 10,
label: "Return for 2 adults"
},
{
id: "return-solo",
origin: "BLQ",
destination: "DFW",
earliest: "2026-06-28",
latest: "2026-07-05",
label: "Return for 1 adult"
}
],
passengerGroups: [
{
id: "pair",
adults: 2,
label: "2 adults traveling together"
},
{
id: "solo",
adults: 1,
label: "1 adult returning separately"
}
],
legAssignments: [
{
legId: "outbound",
passengerGroupIds: ["pair", "solo"]
},
{
legId: "return-pair",
passengerGroupIds: ["pair"]
},
{
legId: "return-solo",
passengerGroupIds: ["solo"]
}
],
recipientEmail: "stefano@fiorinis.com",
preferences: {
preferOneStop: true,
maxStops: 1,
maxLayoverHours: 6,
flexibleDates: true,
excludeAirlines: ["Turkish Airlines"],
excludeCountries: ["TR"],
requireAirlineDirectCrossCheck: true,
specialConstraints: [
"Exclude itineraries operated by airlines based in Arab or Middle Eastern countries.",
"Exclude itineraries routing through airports in Arab or Middle Eastern countries.",
"Start from a fresh browser/session profile for this search."
],
geoPricingMarket: "Thailand",
marketCountry: "TH",
normalizeCurrencyTo: "USD"
}
};

View File

@@ -0,0 +1,59 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { FlightRunState } from "./types.js";
const DEFAULT_STATE_DIR = path.join(
os.homedir(),
".openclaw",
"workspace",
"state",
"flight-finder"
);
export function getFlightFinderStatePath(
baseDir = DEFAULT_STATE_DIR
): string {
return path.join(baseDir, "flight-finder-run.json");
}
export async function saveFlightFinderRunState(
state: FlightRunState,
baseDir = DEFAULT_STATE_DIR
): Promise<string> {
await fs.mkdir(baseDir, { recursive: true });
const statePath = getFlightFinderStatePath(baseDir);
await fs.writeFile(statePath, JSON.stringify(state, null, 2));
return statePath;
}
export async function loadFlightFinderRunState(
baseDir = DEFAULT_STATE_DIR
): Promise<FlightRunState | null> {
const statePath = getFlightFinderStatePath(baseDir);
try {
const raw = await fs.readFile(statePath, "utf8");
return JSON.parse(raw) as FlightRunState;
} catch (error) {
const maybeNodeError = error as NodeJS.ErrnoException;
if (maybeNodeError.code === "ENOENT") {
return null;
}
throw error;
}
}
export async function clearFlightFinderRunState(
baseDir = DEFAULT_STATE_DIR
): Promise<void> {
const statePath = getFlightFinderStatePath(baseDir);
try {
await fs.unlink(statePath);
} catch (error) {
const maybeNodeError = error as NodeJS.ErrnoException;
if (maybeNodeError.code !== "ENOENT") {
throw error;
}
}
}

View File

@@ -0,0 +1,81 @@
import type {
FlightReportRequest,
FlightSearchPlan,
FlightSearchSourceFinding,
FlightSearchSourceName,
FlightSearchSourceViability
} from "./types.js";
export const SOURCE_SILENT_TIMEOUT_MS = 45_000;
export const SOURCE_TOTAL_TIMEOUT_MS = 180_000;
export const RUN_TOTAL_TIMEOUT_MS = 900_000;
export const VPN_CONNECT_TIMEOUT_MS = 30_000;
export const VPN_DISCONNECT_TIMEOUT_MS = 15_000;
const DEFAULT_SOURCE_ORDER: FlightSearchSourceName[] = [
"kayak",
"skyscanner",
"expedia",
"airline-direct"
];
function findingFor(
source: FlightSearchSourceName,
findings: FlightSearchSourceFinding[]
): FlightSearchSourceFinding | undefined {
return findings.find((entry) => entry.source === source);
}
function sourceStatus(
source: FlightSearchSourceName,
findings: FlightSearchSourceFinding[]
): FlightSearchSourceViability {
return findingFor(source, findings)?.status || "viable";
}
export function buildFlightSearchPlan(
request: FlightReportRequest,
findings: FlightSearchSourceFinding[] = []
): FlightSearchPlan {
const sourceOrder = DEFAULT_SOURCE_ORDER.map((source) => {
const status = sourceStatus(source, findings);
const finding = findingFor(source, findings);
const required = source !== "airline-direct";
const enabled = status !== "blocked";
const reason =
finding?.notes[0] ||
(source === "airline-direct"
? "Best-effort airline direct cross-check."
: "Primary bounded search source.");
return {
source,
enabled,
required,
status,
silentTimeoutMs: SOURCE_SILENT_TIMEOUT_MS,
totalTimeoutMs: SOURCE_TOTAL_TIMEOUT_MS,
reason
};
});
const degradedReasons = sourceOrder
.filter((source) => source.status !== "viable")
.map((source) =>
source.enabled
? `${source.source} is degraded for this run: ${source.reason}`
: `${source.source} is blocked for this run: ${source.reason}`
);
return {
sourceOrder,
vpn: {
enabled: Boolean(request.preferences.marketCountry),
marketCountry: request.preferences.marketCountry || null,
connectTimeoutMs: VPN_CONNECT_TIMEOUT_MS,
disconnectTimeoutMs: VPN_DISCONNECT_TIMEOUT_MS,
fallbackMode: "default-market"
},
degradedReasons
};
}

View File

@@ -0,0 +1,194 @@
export type FlightPassengerGroup = {
id: string;
adults: number;
children?: number;
infants?: number;
label?: string;
};
export type FlightLegWindow = {
id: string;
origin: string;
destination: string;
earliest?: string;
latest?: string;
relativeToLegId?: string;
minDaysAfter?: number;
maxDaysAfter?: number;
label?: string;
};
export type FlightSearchPreferences = {
cabin?: "economy" | "premium-economy" | "business" | "first";
maxStops?: number;
preferOneStop?: boolean;
maxLayoverHours?: number;
excludeAirlines?: string[];
excludeCountries?: string[];
excludeAirports?: string[];
flexibleDates?: boolean;
requireAirlineDirectCrossCheck?: boolean;
specialConstraints?: string[];
geoPricingMarket?: string | null;
marketCountry?: string | null;
normalizeCurrencyTo?: "USD";
};
export type FlightReportRequest = {
tripName?: string;
legs: FlightLegWindow[];
passengerGroups: FlightPassengerGroup[];
legAssignments: Array<{ legId: string; passengerGroupIds: string[] }>;
recipientEmail?: string | null;
preferences: FlightSearchPreferences;
};
export type FlightReportStatus = {
needsMissingInputs: string[];
readyToSearch: boolean;
pdfReady: boolean;
emailReady: boolean;
lastCompletedPhase?: "intake" | "search" | "ranking" | "render" | "delivery";
};
export type FlightQuote = {
id: string;
source: FlightSearchSourceName;
legId: string;
passengerGroupIds: string[];
bookingLink: string;
itinerarySummary: string;
airlineName?: string;
departureTimeLocal?: string;
arrivalTimeLocal?: string;
stopsText?: string;
layoverText?: string;
totalDurationText?: string;
totalPriceUsd: number;
displayPriceUsd: string;
originalCurrency?: string;
originalTotalPrice?: number;
directBookingUrl?: string | null;
crossCheckStatus?: "verified" | "not-available" | "failed";
notes?: string[];
};
export type FlightRecommendationOption = {
id: string;
title: string;
quoteIds: string[];
totalPriceUsd: number;
rationale: string;
backup?: boolean;
};
export type FlightSearchSourceName =
| "kayak"
| "skyscanner"
| "expedia"
| "airline-direct";
export type FlightSearchSourceViability = "viable" | "degraded" | "blocked";
export type FlightSearchSourceFinding = {
source: FlightSearchSourceName;
status: FlightSearchSourceViability;
checkedAt: string;
notes: string[];
evidence?: string[];
};
export type FlightSearchPlanSource = {
source: FlightSearchSourceName;
enabled: boolean;
required: boolean;
status: FlightSearchSourceViability;
silentTimeoutMs: number;
totalTimeoutMs: number;
reason: string;
};
export type FlightSearchPlan = {
sourceOrder: FlightSearchPlanSource[];
vpn: {
enabled: boolean;
marketCountry: string | null;
connectTimeoutMs: number;
disconnectTimeoutMs: number;
fallbackMode: "default-market";
};
degradedReasons: string[];
};
export type FlightReportRequestDraft = {
tripName?: string | null;
legs?: Array<Partial<FlightLegWindow> | null | undefined> | null;
passengerGroups?: Array<Partial<FlightPassengerGroup> | null | undefined> | null;
legAssignments?:
| Array<
| {
legId?: string | null;
passengerGroupIds?: Array<string | null | undefined> | null;
}
| null
| undefined
>
| null;
recipientEmail?: string | null;
preferences?: Partial<FlightSearchPreferences> | null;
};
export type NormalizedFlightReportRequest = {
request: FlightReportRequest;
readyToSearch: boolean;
missingInputs: string[];
missingSearchInputs: string[];
missingDeliveryInputs: string[];
warnings: string[];
};
export type FlightReportPayload = {
request: FlightReportRequest;
searchExecution: {
freshSearch: boolean;
startedAt: string;
completedAt: string;
artifactsRoot: string;
notes?: string[];
};
sourceFindings: FlightSearchSourceFinding[];
quotes: FlightQuote[];
rankedOptions: FlightRecommendationOption[];
executiveSummary: string[];
reportWarnings: string[];
degradedReasons: string[];
comparisonCurrency: "USD";
marketCountryUsed?: string | null;
lastCompletedPhase?: "intake" | "search" | "ranking" | "render" | "delivery";
renderedPdfPath?: string | null;
deliveredTo?: string | null;
emailAuthorized?: boolean;
generatedAt: string;
};
export type FlightReportStatusResult = FlightReportStatus & {
chatSummaryReady: boolean;
terminalOutcome:
| "ready"
| "missing-inputs"
| "all-sources-failed"
| "report-incomplete";
degraded: boolean;
degradedReasons: string[];
blockingReason?: string;
};
export type FlightRunState = {
request: FlightReportRequest;
lastCompletedPhase: "intake" | "search" | "ranking" | "render" | "delivery";
updatedAt: string;
reportWarnings: string[];
degradedReasons: string[];
sourceFindings: FlightSearchSourceFinding[];
comparisonCurrency: "USD";
};

View File

@@ -0,0 +1,38 @@
import test from "node:test";
import assert from "node:assert/strict";
import { normalizePriceToUsd } from "../src/price-normalization.js";
test("normalizePriceToUsd passes through USD values", () => {
const result = normalizePriceToUsd({
amount: 2847,
currency: "USD"
});
assert.equal(result.amountUsd, 2847);
assert.equal(result.currency, "USD");
});
test("normalizePriceToUsd converts foreign currencies when a rate is supplied", () => {
const result = normalizePriceToUsd({
amount: 100,
currency: "EUR",
exchangeRates: {
EUR: 1.08
}
});
assert.equal(result.amountUsd, 108);
assert.match(result.notes.join(" "), /EUR/i);
});
test("normalizePriceToUsd rejects missing exchange rates", () => {
assert.throws(
() =>
normalizePriceToUsd({
amount: 100,
currency: "EUR"
}),
/exchange rate/i
);
});

View File

@@ -0,0 +1,44 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildLukeDeliveryPlan } from "../src/report-delivery.js";
test("buildLukeDeliveryPlan requires a plausible recipient email", () => {
const plan = buildLukeDeliveryPlan({
recipientEmail: "invalid-email",
subject: "DFW ↔ BLQ flight report",
body: "Summary",
attachmentPath: "/tmp/report.pdf"
});
assert.equal(plan.ready, false);
assert.equal(plan.needsRecipientEmail, true);
assert.equal(plan.command, null);
});
test("buildLukeDeliveryPlan uses Luke's wrapper path when recipient is valid", () => {
const plan = buildLukeDeliveryPlan({
recipientEmail: "stefano@fiorinis.com",
subject: "DFW ↔ BLQ flight report",
body: "Summary",
attachmentPath: "/tmp/report.pdf"
});
assert.equal(plan.ready, true);
assert.match(String(plan.command), /gog-luke gmail send/);
assert.match(String(plan.command), /stefano@fiorinis.com/);
assert.match(String(plan.command), /report\.pdf/);
});
test("buildLukeDeliveryPlan safely quotes shell-sensitive content", () => {
const plan = buildLukeDeliveryPlan({
recipientEmail: "stefano@fiorinis.com",
subject: "Luke's flight report; rm -rf /",
body: "It's ready.",
attachmentPath: "/tmp/report's-final.pdf"
});
assert.equal(plan.ready, true);
assert.match(String(plan.command), /--subject 'Luke'"'"'s flight report; rm -rf \/'/);
assert.match(String(plan.command), /--attach '\/tmp\/report'"'"'s-final\.pdf'/);
});

View File

@@ -0,0 +1,273 @@
import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import fs from "node:fs";
import {
describeQuoteBookingLinks,
formatArtifactsRoot,
renderFlightReportPdf,
ReportValidationError
} from "../src/report-pdf.js";
import type { FlightReportPayload } from "../src/types.js";
import {
DFW_BLQ_2026_PROMPT_DRAFT,
normalizeFlightReportRequest
} from "../src/request-normalizer.js";
function samplePayload(): FlightReportPayload {
return {
request: normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request,
searchExecution: {
freshSearch: true,
startedAt: "2026-03-30T20:45:00Z",
completedAt: "2026-03-30T21:00:00Z",
artifactsRoot: "/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-exec",
notes: ["Fresh bounded search executed for this report."]
},
sourceFindings: [
{
source: "kayak",
status: "viable",
checkedAt: "2026-03-30T21:00:00Z",
notes: ["Returned usable options."]
}
],
quotes: [
{
id: "quote-1",
source: "kayak",
legId: "outbound",
passengerGroupIds: ["pair", "solo"],
bookingLink: "https://example.com/quote-1",
itinerarySummary: "DFW -> BLQ via LHR",
totalPriceUsd: 2847,
displayPriceUsd: "$2,847"
}
],
rankedOptions: [
{
id: "primary",
title: "Best overall itinerary",
quoteIds: ["quote-1"],
totalPriceUsd: 2847,
rationale: "Best price-to-convenience tradeoff."
}
],
executiveSummary: ["Best fare currently found is $2,847 total for 3 adults."],
reportWarnings: [],
degradedReasons: [],
comparisonCurrency: "USD",
generatedAt: "2026-03-30T21:00:00Z",
lastCompletedPhase: "ranking"
};
}
function countPdfPages(outputPath: string): number {
return fs.readFileSync(outputPath, "latin1").split("/Type /Page").length - 1;
}
test("renderFlightReportPdf writes a non-empty PDF", async () => {
const outputPath = path.join(os.tmpdir(), `flight-finder-${Date.now()}.pdf`);
await renderFlightReportPdf(samplePayload(), outputPath);
assert.ok(fs.existsSync(outputPath));
assert.ok(fs.statSync(outputPath).size > 0);
});
test("renderFlightReportPdf embeds booking links and only includes direct-airline URLs when captured", async () => {
const outputPath = path.join(os.tmpdir(), `flight-finder-links-${Date.now()}.pdf`);
const payload = samplePayload();
payload.quotes = [
{
...payload.quotes[0],
bookingLink: "https://example.com/quote-1",
directBookingUrl: null
},
{
id: "quote-2",
source: "airline-direct",
legId: "return-pair",
passengerGroupIds: ["pair"],
bookingLink: "https://example.com/quote-2",
directBookingUrl: "https://www.britishairways.com/",
airlineName: "British Airways",
itinerarySummary: "BLQ -> DFW via LHR",
totalPriceUsd: 1100,
displayPriceUsd: "$1,100"
}
];
payload.rankedOptions = [
{
id: "primary",
title: "Best overall itinerary",
quoteIds: ["quote-1", "quote-2"],
totalPriceUsd: 3947,
rationale: "Best price-to-convenience tradeoff."
}
];
await renderFlightReportPdf(payload, outputPath);
const pdfContents = fs.readFileSync(outputPath, "latin1");
assert.match(pdfContents, /https:\/\/example\.com\/quote-1/);
assert.match(pdfContents, /https:\/\/example\.com\/quote-2/);
assert.match(pdfContents, /https:\/\/www\.britishairways\.com\//);
assert.doesNotMatch(pdfContents, /Direct airline booking: not captured in this run[\s\S]*https:\/\/example\.com\/quote-1/);
});
test("renderFlightReportPdf avoids raw absolute artifact paths and keeps page count bounded for a long report", async () => {
const outputPath = path.join(os.tmpdir(), `flight-finder-long-${Date.now()}.pdf`);
const payload = samplePayload();
payload.searchExecution.artifactsRoot =
"/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-fresh-185742";
payload.quotes = [
{
...payload.quotes[0],
id: "quote-1",
notes: [
"Observed on fresh KAYAK sweep at $794 per traveler for 3 adults.",
"Skyscanner TH/USD corroborated the same Madrid pattern.",
"Expedia also found a nearby fare band."
]
},
{
id: "quote-2",
source: "kayak",
legId: "return-pair",
passengerGroupIds: ["pair"],
bookingLink: "https://example.com/quote-2",
itinerarySummary: "BLQ -> DFW via LHR",
totalPriceUsd: 1316,
displayPriceUsd: "$1,316 total ($658 pp x 2)",
notes: [
"Cheapest valid paired return captured in the fresh KAYAK sweep.",
"Skyscanner did not reproduce the exact low Jun 8 return cleanly.",
"Expedia hit a bot challenge on the Jun 8 paired-return check."
]
},
{
id: "quote-3",
source: "kayak",
legId: "return-solo",
passengerGroupIds: ["solo"],
bookingLink: "https://example.com/quote-3",
itinerarySummary: "BLQ -> DFW via MAD",
totalPriceUsd: 722,
displayPriceUsd: "$722 total",
notes: [
"Lowest valid solo-return fare in the fresh Jul window capture.",
"Skyscanner TH/USD showed the same Madrid connection at about $742.",
"Expedia surfaced a cheaper Turkish option, but it was excluded."
]
}
];
payload.rankedOptions = [
{
id: "primary",
title: "Best overall itinerary",
quoteIds: ["quote-1", "quote-2", "quote-3"],
totalPriceUsd: 4420,
rationale: "Best price-to-convenience tradeoff."
},
{
id: "backup",
title: "Backup itinerary",
quoteIds: ["quote-1", "quote-2", "quote-3"],
totalPriceUsd: 4515,
rationale: "Slightly higher price but still viable."
}
];
payload.executiveSummary = [
"Fresh Thailand-market sweeps shifted the outbound slightly earlier.",
"Lowest valid total observed was $4,420.",
"Cross-source corroboration is strongest for the outbound and solo return."
];
await renderFlightReportPdf(payload, outputPath);
const pdfContents = fs.readFileSync(outputPath, "latin1");
assert.doesNotMatch(pdfContents, /\/Users\/stefano\/\.openclaw\/workspace\/reports\//);
assert.ok(countPdfPages(outputPath) <= 4);
});
test("renderFlightReportPdf rejects incomplete report payloads", async () => {
const outputPath = path.join(os.tmpdir(), `flight-finder-incomplete-${Date.now()}.pdf`);
await assert.rejects(
() =>
renderFlightReportPdf(
{
...samplePayload(),
rankedOptions: [],
executiveSummary: []
},
outputPath
),
ReportValidationError
);
});
test("renderFlightReportPdf rejects payloads that are not marked as fresh-search output", async () => {
const outputPath = path.join(os.tmpdir(), `flight-finder-stale-${Date.now()}.pdf`);
await assert.rejects(
() =>
renderFlightReportPdf(
{
...samplePayload(),
searchExecution: {
...samplePayload().searchExecution,
freshSearch: false
}
},
outputPath
),
ReportValidationError
);
});
test("describeQuoteBookingLinks includes both aggregator and airline-direct links when captured", () => {
const payload = samplePayload();
const quote = {
...payload.quotes[0],
airlineName: "British Airways",
directBookingUrl: "https://www.britishairways.com/"
};
assert.deepEqual(describeQuoteBookingLinks(quote), [
{
label: "Search / book this fare",
text: "Search / book this fare: $2,847 via kayak",
url: "https://example.com/quote-1"
},
{
label: "Direct airline booking",
text: "Direct airline booking: British Airways",
url: "https://www.britishairways.com/"
}
]);
});
test("describeQuoteBookingLinks calls out missing direct-airline links explicitly", () => {
const payload = samplePayload();
assert.deepEqual(describeQuoteBookingLinks(payload.quotes[0]), [
{
label: "Search / book this fare",
text: "Search / book this fare: $2,847 via kayak",
url: "https://example.com/quote-1"
},
{
label: "Direct airline booking",
text: "Direct airline booking: not captured in this run",
url: null
}
]);
});
test("formatArtifactsRoot only shortens known workspace report paths", () => {
assert.equal(
formatArtifactsRoot("/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-fresh-185742"),
"reports/dfw-blq-flight-report-2026-03-30-fresh-185742"
);
assert.equal(formatArtifactsRoot("/tmp/run-123"), "run-123");
});

View File

@@ -0,0 +1,106 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
DFW_BLQ_2026_PROMPT_DRAFT,
normalizeFlightReportRequest
} from "../src/request-normalizer.js";
import { getFlightReportStatus } from "../src/report-status.js";
import type { FlightReportPayload } from "../src/types.js";
function buildPayload(): FlightReportPayload {
return {
request: normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request,
searchExecution: {
freshSearch: true,
startedAt: "2026-03-30T20:45:00Z",
completedAt: "2026-03-30T21:00:00Z",
artifactsRoot: "/tmp/flight-finder-report-status",
notes: ["Fresh bounded search executed for this report."]
},
sourceFindings: [
{
source: "kayak",
status: "viable",
checkedAt: "2026-03-30T21:00:00Z",
notes: ["Returned usable options."]
}
],
quotes: [
{
id: "quote-1",
source: "kayak",
legId: "outbound",
passengerGroupIds: ["pair", "solo"],
bookingLink: "https://example.com/quote-1",
itinerarySummary: "DFW -> BLQ via LHR",
totalPriceUsd: 2847,
displayPriceUsd: "$2,847",
crossCheckStatus: "not-available"
}
],
rankedOptions: [
{
id: "primary",
title: "Best overall itinerary",
quoteIds: ["quote-1"],
totalPriceUsd: 2847,
rationale: "Best price-to-convenience tradeoff."
}
],
executiveSummary: ["Best fare currently found is $2,847 total for 3 adults."],
reportWarnings: [],
degradedReasons: [],
comparisonCurrency: "USD",
generatedAt: "2026-03-30T21:00:00Z",
lastCompletedPhase: "ranking"
};
}
test("getFlightReportStatus treats missing recipient email as delivery-only blocker", () => {
const normalized = normalizeFlightReportRequest({
...DFW_BLQ_2026_PROMPT_DRAFT,
recipientEmail: null
});
const payload = {
...buildPayload(),
request: normalized.request
};
const status = getFlightReportStatus(normalized, payload);
assert.equal(status.readyToSearch, true);
assert.equal(status.pdfReady, true);
assert.equal(status.emailReady, false);
assert.deepEqual(status.needsMissingInputs, ["recipient email"]);
});
test("getFlightReportStatus marks all-sources-failed as a blocked but chat-summarizable outcome", () => {
const normalized = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT);
const status = getFlightReportStatus(normalized, {
...buildPayload(),
sourceFindings: [
{
source: "kayak",
status: "blocked",
checkedAt: "2026-03-30T21:00:00Z",
notes: ["Timed out."]
},
{
source: "skyscanner",
status: "blocked",
checkedAt: "2026-03-30T21:00:00Z",
notes: ["Timed out."]
}
],
quotes: [],
rankedOptions: [],
executiveSummary: [],
degradedReasons: ["All configured travel sources failed."]
});
assert.equal(status.terminalOutcome, "all-sources-failed");
assert.equal(status.chatSummaryReady, true);
assert.equal(status.pdfReady, false);
assert.equal(status.degraded, true);
});

View File

@@ -0,0 +1,98 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
DFW_BLQ_2026_PROMPT_DRAFT,
normalizeFlightReportRequest
} from "../src/request-normalizer.js";
test("normalizeFlightReportRequest preserves the legacy DFW-BLQ split-return structure", () => {
const result = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT);
assert.equal(result.readyToSearch, true);
assert.deepEqual(result.missingSearchInputs, []);
assert.deepEqual(result.missingDeliveryInputs, []);
assert.equal(result.request.preferences.marketCountry, "TH");
assert.equal(result.request.preferences.normalizeCurrencyTo, "USD");
assert.equal(result.request.legs.length, 3);
assert.equal(result.request.passengerGroups.length, 2);
assert.deepEqual(
result.request.legAssignments.find((entry) => entry.legId === "outbound")?.passengerGroupIds,
["pair", "solo"]
);
assert.equal(
result.request.preferences.requireAirlineDirectCrossCheck,
true
);
});
test("normalizeFlightReportRequest keeps email missing as a delivery gate, not a search blocker", () => {
const result = normalizeFlightReportRequest({
...DFW_BLQ_2026_PROMPT_DRAFT,
recipientEmail: null
});
assert.equal(result.readyToSearch, true);
assert.deepEqual(result.missingSearchInputs, []);
assert.deepEqual(result.missingDeliveryInputs, ["recipient email"]);
assert.match(result.warnings.join(" "), /recipient email/i);
});
test("normalizeFlightReportRequest reports missing search-critical inputs explicitly", () => {
const result = normalizeFlightReportRequest({
tripName: "Incomplete flight request",
legs: [
{
id: "outbound",
origin: "DFW",
destination: "",
label: "Outbound"
}
],
passengerGroups: [],
legAssignments: []
});
assert.equal(result.readyToSearch, false);
assert.match(result.missingSearchInputs.join(" | "), /origin and destination/i);
assert.match(result.missingSearchInputs.join(" | "), /date window/i);
assert.match(result.missingSearchInputs.join(" | "), /passenger groups/i);
assert.match(result.missingSearchInputs.join(" | "), /assignments/i);
});
test("normalizeFlightReportRequest validates marketCountry as an ISO alpha-2 code", () => {
assert.throws(
() =>
normalizeFlightReportRequest({
...DFW_BLQ_2026_PROMPT_DRAFT,
preferences: {
...DFW_BLQ_2026_PROMPT_DRAFT.preferences,
marketCountry: "Germany"
}
}),
/ISO 3166-1 alpha-2/i
);
});
test("normalizeFlightReportRequest uppercases a lowercase marketCountry code", () => {
const result = normalizeFlightReportRequest({
...DFW_BLQ_2026_PROMPT_DRAFT,
preferences: {
...DFW_BLQ_2026_PROMPT_DRAFT.preferences,
marketCountry: "th"
}
});
assert.equal(result.request.preferences.marketCountry, "TH");
});
test("normalizeFlightReportRequest flags a malformed recipient email without blocking search readiness", () => {
const result = normalizeFlightReportRequest({
...DFW_BLQ_2026_PROMPT_DRAFT,
recipientEmail: "stefano-at-example"
});
assert.equal(result.readyToSearch, true);
assert.deepEqual(result.missingDeliveryInputs, ["valid recipient email"]);
assert.match(result.warnings.join(" "), /malformed/i);
});

View File

@@ -0,0 +1,42 @@
import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import fs from "node:fs/promises";
import {
clearFlightFinderRunState,
loadFlightFinderRunState,
saveFlightFinderRunState
} from "../src/run-state.js";
import {
DFW_BLQ_2026_PROMPT_DRAFT,
normalizeFlightReportRequest
} from "../src/request-normalizer.js";
test("save/load/clearFlightFinderRunState persists the resumable phase context", async () => {
const baseDir = await fs.mkdtemp(
path.join(os.tmpdir(), "flight-finder-run-state-")
);
const request = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request;
await saveFlightFinderRunState(
{
request,
lastCompletedPhase: "search",
updatedAt: "2026-03-30T21:00:00Z",
reportWarnings: [],
degradedReasons: [],
sourceFindings: [],
comparisonCurrency: "USD"
},
baseDir
);
const loaded = await loadFlightFinderRunState(baseDir);
assert.equal(loaded?.lastCompletedPhase, "search");
assert.equal(loaded?.request.tripName, "DFW ↔ BLQ flight report");
await clearFlightFinderRunState(baseDir);
assert.equal(await loadFlightFinderRunState(baseDir), null);
});

View File

@@ -0,0 +1,46 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
DFW_BLQ_2026_PROMPT_DRAFT,
normalizeFlightReportRequest
} from "../src/request-normalizer.js";
import {
VPN_CONNECT_TIMEOUT_MS,
VPN_DISCONNECT_TIMEOUT_MS,
buildFlightSearchPlan
} from "../src/search-orchestration.js";
test("buildFlightSearchPlan activates VPN only when marketCountry is explicit", () => {
const request = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request;
const plan = buildFlightSearchPlan(request);
assert.equal(plan.vpn.enabled, true);
assert.equal(plan.vpn.marketCountry, "TH");
assert.equal(plan.vpn.connectTimeoutMs, VPN_CONNECT_TIMEOUT_MS);
assert.equal(plan.vpn.disconnectTimeoutMs, VPN_DISCONNECT_TIMEOUT_MS);
});
test("buildFlightSearchPlan keeps airline-direct best-effort when degraded", () => {
const request = normalizeFlightReportRequest({
...DFW_BLQ_2026_PROMPT_DRAFT,
preferences: {
...DFW_BLQ_2026_PROMPT_DRAFT.preferences,
marketCountry: null
}
}).request;
const plan = buildFlightSearchPlan(request, [
{
source: "airline-direct",
status: "degraded",
checkedAt: "2026-03-30T21:00:00Z",
notes: ["Direct booking shell loads but search completion is unreliable."]
}
]);
assert.equal(plan.vpn.enabled, false);
const directSource = plan.sourceOrder.find((entry) => entry.source === "airline-direct");
assert.equal(directSource?.enabled, true);
assert.equal(directSource?.required, false);
assert.match(plan.degradedReasons.join(" "), /airline-direct/i);
});

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node"],
"outDir": "./dist",
"rootDir": "."
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -41,7 +41,13 @@ node scripts/nordvpn-client.js status --debug
- use NordLynx/WireGuard through `wireguard-go` and `wireguard-tools` - use NordLynx/WireGuard through `wireguard-go` and `wireguard-tools`
- `install` bootstraps them with Homebrew - `install` bootstraps them with Homebrew
- `login` validates the token for the WireGuard backend - `login` validates the token for the WireGuard backend
- the generated WireGuard config stays free of `DNS = ...`
- `connect` now requires a bounded persistence gate plus a verified exit before success is declared
- the skill snapshots and applies NordVPN DNS only to eligible physical services while connected
- NordVPN DNS is applied only after the tunnel remains up, the final liveness check still shows the requested exit, and system hostname resolution still works afterward
- `disconnect` restores the saved DNS/search-domain state even if the tunnel state is stale
- Tailscale is suspended before connect and resumed after disconnect or failed connect - Tailscale is suspended before connect and resumed after disconnect or failed connect
- the skill writes a short-lived Tailscale suppression marker during VPN connect so host watchdogs do not immediately re-run `tailscale up`
- `NordVPN.app` may remain installed but is only the manual fallback - `NordVPN.app` may remain installed but is only the manual fallback
## Credentials ## Credentials
@@ -75,6 +81,10 @@ Exact `visudo` rule for the installed OpenClaw skill:
stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh probe, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh up, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh down stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh probe, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh up, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh down
``` ```
Operational note:
- the persistence gate reuses the already-allowed `probe` action to confirm the live `utun*` WireGuard runtime and does not require extra sudoers actions beyond `probe`, `up`, and `down`
## Agent Guidance ## Agent Guidance
- run `status` first when the machine state is unclear - run `status` first when the machine state is unclear
@@ -83,10 +93,11 @@ stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-c
- use `connect` before location-sensitive skills such as `web-automation` - use `connect` before location-sensitive skills such as `web-automation`
- use `verify` after connect when you need an explicit location check - use `verify` after connect when you need an explicit location check
- use `disconnect` after the follow-up task - use `disconnect` after the follow-up task
- if `connect` fails its persistence or final verification gate, treat that as a safe rollback, not a partial success
## Output Rules ## Output Rules
- normal JSON output redacts local path metadata - normal JSON output redacts local path metadata and helper-hardening diagnostics
- use `--debug` only when deeper troubleshooting requires internal local paths and helper/config metadata - use `--debug` only when deeper troubleshooting requires internal local paths and helper/config metadata
## Troubleshooting Cues ## Troubleshooting Cues
@@ -98,6 +109,7 @@ stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-c
- connect succeeds but final state looks inconsistent: - connect succeeds but final state looks inconsistent:
- rely on the verified public IP/location first - rely on the verified public IP/location first
- then inspect `status --debug` - then inspect `status --debug`
- `verified: true` but `persistence.stable: false` should not happen anymore; if it does, the skill should roll back instead of pinning DNS
- disconnect should leave: - disconnect should leave:
- normal public IP restored - normal public IP restored
- no active WireGuard state - no active WireGuard state

View File

@@ -14,7 +14,10 @@ const WG_STATE_DIR = path.join(STATE_DIR, "wireguard");
const WG_CONFIG_PATH = path.join(WG_STATE_DIR, `${MAC_WG_INTERFACE}.conf`); const WG_CONFIG_PATH = path.join(WG_STATE_DIR, `${MAC_WG_INTERFACE}.conf`);
const AUTH_CACHE_PATH = path.join(STATE_DIR, "auth.json"); const AUTH_CACHE_PATH = path.join(STATE_DIR, "auth.json");
const LAST_CONNECTION_PATH = path.join(STATE_DIR, "last-connection.json"); const LAST_CONNECTION_PATH = path.join(STATE_DIR, "last-connection.json");
const MAC_DNS_STATE_PATH = path.join(STATE_DIR, "dns.json");
const MAC_TAILSCALE_STATE_PATH = path.join(STATE_DIR, "tailscale.json"); const MAC_TAILSCALE_STATE_PATH = path.join(STATE_DIR, "tailscale.json");
const MAC_TAILSCALE_SUPPRESS_PATH = path.join(STATE_DIR, "tailscale-suppressed");
const OPERATION_LOCK_PATH = path.join(STATE_DIR, "operation.lock");
const OPENCLAW_NORDVPN_CREDENTIALS_DIR = path.join( const OPENCLAW_NORDVPN_CREDENTIALS_DIR = path.join(
os.homedir(), os.homedir(),
".openclaw", ".openclaw",
@@ -37,7 +40,13 @@ const MAC_WG_HELPER_PATH = path.join(
const CLIENT_IPV4 = "10.5.0.2"; const CLIENT_IPV4 = "10.5.0.2";
const DNS_FALLBACK_RESOLVERS = ["1.1.1.1", "8.8.8.8"]; const DNS_FALLBACK_RESOLVERS = ["1.1.1.1", "8.8.8.8"];
const NORDVPN_MAC_DNS_SERVERS = ["103.86.96.100", "103.86.99.100"]; const NORDVPN_MAC_DNS_SERVERS = ["103.86.96.100", "103.86.99.100"];
const REDACTED_PATH_KEYS = new Set(["cliPath", "appPath", "configPath", "helperPath", "tokenSource"]); const CONNECT_PERSISTENCE_ATTEMPTS = 6;
const CONNECT_PERSISTENCE_DELAY_MS = 2000;
const CONNECT_TOTAL_TIMEOUT_MS = 90000;
const POST_DNS_RESOLUTION_HOSTNAMES = ["www.google.com", "api.openai.com", "docs.openclaw.ai"];
const POST_DNS_RESOLUTION_TIMEOUT_MS = 4000;
const POST_DNS_SETTLE_DELAY_MS = 1500;
const REDACTED_PATH_KEYS = new Set(["cliPath", "appPath", "configPath", "helperPath", "tokenSource", "helperSecurity"]);
function sanitizeOutputPayload(payload) { function sanitizeOutputPayload(payload) {
if (Array.isArray(payload)) { if (Array.isArray(payload)) {
@@ -252,6 +261,78 @@ function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
function processExists(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error && error.code === "EPERM";
}
}
function isOperationLockStale(lockRecord, options = {}) {
if (!lockRecord || typeof lockRecord !== "object") return true;
const now = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : CONNECT_TOTAL_TIMEOUT_MS;
const startedAtMs = Number.isFinite(lockRecord.startedAtMs)
? lockRecord.startedAtMs
: Date.parse(lockRecord.startedAt || "");
if (!processExists(lockRecord.pid)) {
return true;
}
if (!Number.isFinite(startedAtMs)) {
return true;
}
return now - startedAtMs > maxAgeMs;
}
function cleanupOperationLock(lockPath = OPERATION_LOCK_PATH) {
try {
if (lockPath && fs.existsSync(lockPath)) {
fs.unlinkSync(lockPath);
}
} catch {
// Ignore cleanup errors.
}
}
function acquireOperationLock(action, options = {}) {
const lockPath = options.lockPath || OPERATION_LOCK_PATH;
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : CONNECT_TOTAL_TIMEOUT_MS;
const existing = readJsonFile(lockPath);
if (existing) {
if (isOperationLockStale(existing, { nowMs, maxAgeMs })) {
cleanupOperationLock(lockPath);
} else {
const activeAction = existing.action || "unknown";
throw new Error(`Another nordvpn-client ${activeAction} operation is already running.`);
}
}
const record = {
action,
pid: process.pid,
startedAt: new Date(nowMs).toISOString(),
startedAtMs: nowMs,
};
writeJsonFile(lockPath, record);
return {
lockPath,
record,
release() {
const current = readJsonFile(lockPath);
if (current && current.pid === record.pid && current.startedAtMs === record.startedAtMs) {
cleanupOperationLock(lockPath);
}
},
};
}
function detectMacWireguardActiveFromIfconfig(ifconfigOutput) { function detectMacWireguardActiveFromIfconfig(ifconfigOutput) {
let inUtunBlock = false; let inUtunBlock = false;
const lines = `${ifconfigOutput || ""}`.split("\n"); const lines = `${ifconfigOutput || ""}`.split("\n");
@@ -276,6 +357,281 @@ function buildMacTailscaleState(tailscaleWasActive) {
return { tailscaleWasActive: Boolean(tailscaleWasActive) }; return { tailscaleWasActive: Boolean(tailscaleWasActive) };
} }
function markMacTailscaleRecoverySuppressed() {
ensureDir(STATE_DIR);
writeTextFile(MAC_TAILSCALE_SUPPRESS_PATH, `${new Date().toISOString()}\n`, 0o600);
}
function clearMacTailscaleRecoverySuppressed() {
try {
fs.unlinkSync(MAC_TAILSCALE_SUPPRESS_PATH);
} catch {
// Ignore cleanup errors.
}
}
function inspectMacWireguardHelperSecurity(helperPath, deps = {}) {
const fileExistsFn = deps.fileExists || fileExists;
const statSyncFn = deps.statSync || fs.statSync;
if (!helperPath || !fileExistsFn(helperPath)) {
return {
exists: false,
hardened: false,
ownerUid: null,
groupGid: null,
mode: null,
reason: "Helper is missing.",
};
}
let stat;
try {
stat = statSyncFn(helperPath);
} catch (error) {
return {
exists: true,
hardened: false,
ownerUid: null,
groupGid: null,
mode: null,
reason: error.message || "Unable to inspect helper security.",
};
}
const mode = stat.mode & 0o777;
if (stat.uid !== 0) {
return {
exists: true,
hardened: false,
ownerUid: stat.uid,
groupGid: stat.gid,
mode,
reason: "Helper must be root-owned before privileged actions are trusted.",
};
}
if ((mode & 0o022) !== 0) {
return {
exists: true,
hardened: false,
ownerUid: stat.uid,
groupGid: stat.gid,
mode,
reason: "Helper must not be group- or world-writable.",
};
}
return {
exists: true,
hardened: true,
ownerUid: stat.uid,
groupGid: stat.gid,
mode,
reason: "",
};
}
function trimDiagnosticOutput(value, maxChars = 4000) {
const text = `${value || ""}`.trim();
if (!text) return "";
if (text.length <= maxChars) return text;
return `${text.slice(0, maxChars)}\n...[truncated]`;
}
function summarizeMacWireguardDiagnostics(diagnostics) {
if (!diagnostics) return null;
return {
interfaceName: diagnostics.interfaceName || MAC_WG_INTERFACE,
wgShowCaptured: Boolean(diagnostics.wgShow),
ifconfigCaptured: Boolean(diagnostics.ifconfig),
routesCaptured: Boolean(diagnostics.routes),
processesCaptured: Boolean(diagnostics.processes),
helperSecurity:
diagnostics.helperSecurity && typeof diagnostics.helperSecurity === "object"
? {
hardened: Boolean(diagnostics.helperSecurity.hardened),
reason: diagnostics.helperSecurity.reason || "",
}
: null,
};
}
async function collectMacWireguardDiagnostics(options = {}) {
const runExecFn = options.runExec || runExec;
const interfaceName = options.interfaceName || MAC_WG_INTERFACE;
const wgPath = options.wgPath || commandExists("wg") || "/opt/homebrew/bin/wg";
const helperSecurity =
options.helperSecurity ||
inspectMacWireguardHelperSecurity(options.helperPath || MAC_WG_HELPER_PATH, options.securityDeps || {});
const [wgShow, ifconfig, routes, processes] = await Promise.all([
runExecFn(wgPath, ["show", interfaceName]),
runExecFn("ifconfig", [interfaceName]),
runExecFn("route", ["-n", "get", "default"]),
runExecFn("pgrep", ["-fl", "wireguard-go|wg-quick|nordvpnctl"]),
]);
return {
interfaceName,
helperSecurity,
wgShow: trimDiagnosticOutput(wgShow.stdout || wgShow.stderr || wgShow.error),
ifconfig: trimDiagnosticOutput(ifconfig.stdout || ifconfig.stderr || ifconfig.error),
routes: trimDiagnosticOutput(routes.stdout || routes.stderr || routes.error),
processes: trimDiagnosticOutput(processes.stdout || processes.stderr || processes.error),
};
}
function shouldResumeMacTailscale(state, currentlyActive) {
return Boolean(state && state.tailscaleWasActive && !currentlyActive);
}
function buildMacDnsState(services) {
return {
services: (services || []).map((service) => ({
name: service.name,
dnsServers: Array.isArray(service.dnsServers) ? service.dnsServers : [],
searchDomains: Array.isArray(service.searchDomains) ? service.searchDomains : [],
})),
};
}
function shouldManageMacDnsService(serviceName) {
const normalized = `${serviceName || ""}`.trim().toLowerCase();
if (!normalized) return false;
return !["tailscale", "bridge", "thunderbolt bridge", "loopback", "vpn", "utun"].some((token) => normalized.includes(token));
}
function normalizeMacNetworksetupList(output) {
return `${output || ""}`
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.filter((line) => !line.startsWith("An asterisk"))
.map((line) => (line.startsWith("*") ? line.slice(1).trim() : line))
.filter((line) => shouldManageMacDnsService(line));
}
function normalizeMacDnsCommandOutput(output) {
const text = `${output || ""}`.trim();
if (!text || text.includes("aren't any") || text.includes("There aren't any")) {
return [];
}
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
function isNordDnsServerList(dnsServers) {
if (!Array.isArray(dnsServers) || !dnsServers.length) return false;
const normalized = dnsServers.map((value) => `${value}`.trim()).filter(Boolean);
if (!normalized.length) return false;
return normalized.every((value) => NORDVPN_MAC_DNS_SERVERS.includes(value));
}
function buildAutomaticMacDnsState(serviceNames) {
return {
services: (serviceNames || []).map((name) => ({
name,
dnsServers: [],
searchDomains: [],
})),
};
}
function shouldRejectMacDnsBaseline(state) {
if (!state || !Array.isArray(state.services) || !state.services.length) return false;
return state.services.every((service) => isNordDnsServerList(service.dnsServers || []));
}
async function listMacDnsServices() {
const result = await runExec("networksetup", ["-listallnetworkservices"]);
if (!result.ok) {
throw new Error((result.stderr || result.stdout || result.error).trim() || "networksetup -listallnetworkservices failed");
}
return normalizeMacNetworksetupList(result.stdout);
}
async function readMacDnsStateForService(serviceName) {
const dnsResult = await runExec("networksetup", ["-getdnsservers", serviceName]);
if (!dnsResult.ok) {
throw new Error((dnsResult.stderr || dnsResult.stdout || dnsResult.error).trim() || `networksetup -getdnsservers failed for ${serviceName}`);
}
const searchResult = await runExec("networksetup", ["-getsearchdomains", serviceName]);
if (!searchResult.ok) {
throw new Error((searchResult.stderr || searchResult.stdout || searchResult.error).trim() || `networksetup -getsearchdomains failed for ${serviceName}`);
}
return {
name: serviceName,
dnsServers: normalizeMacDnsCommandOutput(dnsResult.stdout),
searchDomains: normalizeMacDnsCommandOutput(searchResult.stdout),
};
}
async function snapshotMacDnsState() {
const services = await listMacDnsServices();
const snapshot = [];
for (const serviceName of services) {
snapshot.push(await readMacDnsStateForService(serviceName));
}
const state = buildMacDnsState(snapshot);
writeJsonFile(MAC_DNS_STATE_PATH, state);
return state;
}
async function setMacDnsServers(serviceName, dnsServers) {
const args = ["-setdnsservers", serviceName];
args.push(...(dnsServers && dnsServers.length ? dnsServers : ["Empty"]));
const result = await runExec("networksetup", args);
if (!result.ok) {
throw new Error((result.stderr || result.stdout || result.error).trim() || `networksetup -setdnsservers failed for ${serviceName}`);
}
}
async function setMacSearchDomains(serviceName, searchDomains) {
const args = ["-setsearchdomains", serviceName];
args.push(...(searchDomains && searchDomains.length ? searchDomains : ["Empty"]));
const result = await runExec("networksetup", args);
if (!result.ok) {
throw new Error((result.stderr || result.stdout || result.error).trim() || `networksetup -setsearchdomains failed for ${serviceName}`);
}
}
async function applyMacNordDns() {
let snapshot = readJsonFile(MAC_DNS_STATE_PATH);
if (!snapshot || !Array.isArray(snapshot.services) || !snapshot.services.length) {
snapshot = await snapshotMacDnsState();
} else if (shouldRejectMacDnsBaseline(snapshot)) {
const services = await listMacDnsServices();
snapshot = buildAutomaticMacDnsState(services);
writeJsonFile(MAC_DNS_STATE_PATH, snapshot);
}
for (const service of snapshot.services || []) {
await setMacDnsServers(service.name, NORDVPN_MAC_DNS_SERVERS);
await setMacSearchDomains(service.name, []);
}
return snapshot;
}
async function restoreMacDnsIfNeeded() {
const snapshot = readJsonFile(MAC_DNS_STATE_PATH);
if (!snapshot || !Array.isArray(snapshot.services) || !snapshot.services.length) {
return { restored: false };
}
const servicesToRestore = shouldRejectMacDnsBaseline(snapshot)
? buildAutomaticMacDnsState(snapshot.services.map((service) => service.name)).services
: snapshot.services;
for (const service of servicesToRestore) {
await setMacDnsServers(service.name, service.dnsServers || []);
await setMacSearchDomains(service.name, service.searchDomains || []);
}
try {
fs.unlinkSync(MAC_DNS_STATE_PATH);
} catch {
// Ignore unlink errors.
}
return { restored: true };
}
function getMacTailscalePath(deps = {}) { function getMacTailscalePath(deps = {}) {
const commandExistsFn = deps.commandExists || commandExists; const commandExistsFn = deps.commandExists || commandExists;
const fileExistsFn = deps.fileExists || fileExists; const fileExistsFn = deps.fileExists || fileExists;
@@ -308,12 +664,15 @@ async function getMacTailscaleStatus() {
async function stopMacTailscaleIfActive() { async function stopMacTailscaleIfActive() {
const status = await getMacTailscaleStatus(); const status = await getMacTailscaleStatus();
if (!status.active) { if (!status.active) {
clearMacTailscaleRecoverySuppressed();
writeJsonFile(MAC_TAILSCALE_STATE_PATH, buildMacTailscaleState(false)); writeJsonFile(MAC_TAILSCALE_STATE_PATH, buildMacTailscaleState(false));
return { tailscaleWasActive: false }; return { tailscaleWasActive: false };
} }
markMacTailscaleRecoverySuppressed();
const tailscale = getMacTailscalePath(); const tailscale = getMacTailscalePath();
const result = await runExec(tailscale, ["down"]); const result = await runExec(tailscale, ["down"]);
if (!result.ok) { if (!result.ok) {
clearMacTailscaleRecoverySuppressed();
throw new Error((result.stderr || result.stdout || result.error).trim() || "tailscale down failed"); throw new Error((result.stderr || result.stdout || result.error).trim() || "tailscale down failed");
} }
writeJsonFile(MAC_TAILSCALE_STATE_PATH, buildMacTailscaleState(true)); writeJsonFile(MAC_TAILSCALE_STATE_PATH, buildMacTailscaleState(true));
@@ -322,7 +681,19 @@ async function stopMacTailscaleIfActive() {
async function resumeMacTailscaleIfNeeded() { async function resumeMacTailscaleIfNeeded() {
const state = readJsonFile(MAC_TAILSCALE_STATE_PATH); const state = readJsonFile(MAC_TAILSCALE_STATE_PATH);
if (!state || !state.tailscaleWasActive) { let currentStatus = null;
try {
currentStatus = await getMacTailscaleStatus();
} catch {
currentStatus = null;
}
if (!shouldResumeMacTailscale(state, currentStatus && currentStatus.active)) {
clearMacTailscaleRecoverySuppressed();
try {
fs.unlinkSync(MAC_TAILSCALE_STATE_PATH);
} catch {
// Ignore unlink errors.
}
return { restored: false }; return { restored: false };
} }
const tailscale = getMacTailscalePath(); const tailscale = getMacTailscalePath();
@@ -330,6 +701,7 @@ async function resumeMacTailscaleIfNeeded() {
if (!result.ok) { if (!result.ok) {
throw new Error((result.stderr || result.stdout || result.error).trim() || "tailscale up failed"); throw new Error((result.stderr || result.stdout || result.error).trim() || "tailscale up failed");
} }
clearMacTailscaleRecoverySuppressed();
try { try {
fs.unlinkSync(MAC_TAILSCALE_STATE_PATH); fs.unlinkSync(MAC_TAILSCALE_STATE_PATH);
} catch { } catch {
@@ -362,6 +734,52 @@ async function resolveHostnameWithFallback(hostname, options = {}) {
return ""; return "";
} }
async function verifySystemHostnameResolution(hostnames = POST_DNS_RESOLUTION_HOSTNAMES, options = {}) {
const lookup =
options.lookup ||
((hostname) =>
dns.promises.lookup(hostname, {
family: 4,
}));
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : POST_DNS_RESOLUTION_TIMEOUT_MS;
const settleDelayMs =
Number.isFinite(options.settleDelayMs) && options.settleDelayMs >= 0 ? options.settleDelayMs : POST_DNS_SETTLE_DELAY_MS;
const errors = [];
if (settleDelayMs > 0) {
await sleep(settleDelayMs);
}
for (const hostname of hostnames) {
try {
const result = await Promise.race([
Promise.resolve().then(() => lookup(hostname)),
sleep(timeoutMs).then(() => {
throw new Error(`timeout after ${timeoutMs}ms`);
}),
]);
const address = Array.isArray(result) ? result[0] && result[0].address : result && result.address;
if (address) {
return {
ok: true,
hostname,
address,
};
}
errors.push(`${hostname}: no address returned`);
} catch (error) {
errors.push(`${hostname}: ${error.message || String(error)}`);
}
}
return {
ok: false,
hostname: "",
address: "",
error: errors.join("; "),
};
}
function buildLookupResult(address, options = {}) { function buildLookupResult(address, options = {}) {
if (options && options.all) { if (options && options.all) {
return [{ address, family: 4 }]; return [{ address, family: 4 }];
@@ -385,12 +803,134 @@ function cleanupMacWireguardState(paths = {}) {
return { cleaned }; return { cleaned };
} }
function cleanupMacWireguardAndDnsState(paths = {}) {
const cleaned = cleanupMacWireguardState(paths).cleaned;
const dnsStatePath = paths.dnsStatePath || MAC_DNS_STATE_PATH;
let dnsCleaned = false;
try {
if (dnsStatePath && fs.existsSync(dnsStatePath)) {
fs.unlinkSync(dnsStatePath);
dnsCleaned = true;
}
} catch {
// Ignore cleanup errors; caller will rely on current runtime state.
}
return { cleaned: cleaned || dnsCleaned };
}
function shouldAttemptMacWireguardDisconnect(wireguardState) { function shouldAttemptMacWireguardDisconnect(wireguardState) {
if (!wireguardState) return false; if (!wireguardState) return false;
if (wireguardState.active) return true; if (wireguardState.active) return true;
return Boolean(wireguardState.configPath || wireguardState.endpoint || wireguardState.lastConnection); return Boolean(wireguardState.configPath || wireguardState.endpoint || wireguardState.lastConnection);
} }
function isBenignMacWireguardAbsentError(message) {
const normalized = `${message || ""}`.trim().toLowerCase();
return (
normalized.includes("is not a wireguard interface") ||
normalized.includes("is not a known interface") ||
normalized.includes("unable to access interface") ||
normalized.includes("not found")
);
}
function parseMacWireguardHelperStatus(output) {
const parsed = {};
for (const line of `${output || ""}`.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const separator = trimmed.indexOf("=");
if (separator <= 0) continue;
const key = trimmed.slice(0, separator).trim();
const value = trimmed.slice(separator + 1).trim();
parsed[key] = value;
}
return {
active: ["1", "true", "yes", "on"].includes(`${parsed.active || ""}`.toLowerCase()),
interfaceName: parsed.interfaceName || MAC_WG_INTERFACE,
wireguardInterface: parsed.wireguardInterface || null,
configPath: parsed.configPath || null,
raw: `${output || ""}`.trim(),
};
}
async function getMacWireguardHelperStatus(installProbe, options = {}) {
const runSudoWireguardFn = options.runSudoWireguard || runSudoWireguard;
const result = await runSudoWireguardFn(installProbe, "probe");
const parsed = parseMacWireguardHelperStatus(result.stdout || result.stderr || "");
return {
...parsed,
ok: result.ok,
error: result.ok ? "" : (result.stderr || result.stdout || result.error).trim(),
};
}
async function checkMacWireguardPersistence(target, options = {}) {
const attempts = Math.max(1, Number(options.attempts || CONNECT_PERSISTENCE_ATTEMPTS));
const delayMs = Math.max(0, Number(options.delayMs || CONNECT_PERSISTENCE_DELAY_MS));
const getHelperStatus = options.getHelperStatus || (async () => ({ active: false, interfaceName: MAC_WG_INTERFACE }));
const verifyConnectionFn = options.verifyConnection || verifyConnection;
const sleepFn = options.sleep || sleep;
let lastHelperStatus = { active: false, interfaceName: MAC_WG_INTERFACE };
let lastVerified = { ok: false, ipInfo: null };
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
lastHelperStatus = await getHelperStatus();
} catch (error) {
lastHelperStatus = {
active: false,
interfaceName: MAC_WG_INTERFACE,
error: error.message || String(error),
};
}
try {
lastVerified = await verifyConnectionFn(target);
} catch (error) {
lastVerified = {
ok: false,
ipInfo: {
ok: false,
error: error.message || String(error),
},
};
}
if (lastHelperStatus.active && lastVerified.ok) {
return {
stable: true,
attempts: attempt,
helperStatus: lastHelperStatus,
verified: lastVerified,
};
}
if (attempt < attempts) {
await sleepFn(delayMs);
}
}
return {
stable: false,
attempts,
helperStatus: lastHelperStatus,
verified: lastVerified,
};
}
function shouldFinalizeMacWireguardConnect(connectResult, verified, persistence) {
return Boolean(
connectResult &&
connectResult.backend === "wireguard" &&
verified &&
verified.ok &&
persistence &&
persistence.stable
);
}
function normalizeSuccessfulConnectState(state, connectResult, verified) { function normalizeSuccessfulConnectState(state, connectResult, verified) {
if ( if (
!state || !state ||
@@ -539,15 +1079,21 @@ async function probeMacWireguard() {
const wgQuickPath = commandExists("wg-quick"); const wgQuickPath = commandExists("wg-quick");
const wireguardGoPath = commandExists("wireguard-go"); const wireguardGoPath = commandExists("wireguard-go");
const helperPath = fileExists(MAC_WG_HELPER_PATH) ? MAC_WG_HELPER_PATH : null; const helperPath = fileExists(MAC_WG_HELPER_PATH) ? MAC_WG_HELPER_PATH : null;
const helperSecurity = inspectMacWireguardHelperSecurity(helperPath);
const sudoProbe = helperPath ? await runExec("sudo", ["-n", helperPath, "probe"]) : { ok: false }; const sudoProbe = helperPath ? await runExec("sudo", ["-n", helperPath, "probe"]) : { ok: false };
const helperStatus = helperPath && sudoProbe.ok ? parseMacWireguardHelperStatus(sudoProbe.stdout || sudoProbe.stderr || "") : null;
let active = false; let active = false;
let showRaw = ""; let showRaw = "";
let endpoint = ""; let endpoint = "";
let ifconfigRaw = ""; let ifconfigRaw = "";
if (helperStatus) {
active = helperStatus.active;
}
if (wgPath) { if (wgPath) {
const show = await runExec(wgPath, ["show", MAC_WG_INTERFACE]); const show = await runExec(wgPath, ["show", MAC_WG_INTERFACE]);
active = show.ok; active = active || show.ok;
showRaw = (show.stdout || show.stderr).trim(); showRaw = (show.stdout || show.stderr).trim();
if (show.ok) { if (show.ok) {
const endpointResult = await runExec(wgPath, ["show", MAC_WG_INTERFACE, "endpoints"]); const endpointResult = await runExec(wgPath, ["show", MAC_WG_INTERFACE, "endpoints"]);
@@ -571,10 +1117,11 @@ async function probeMacWireguard() {
wgQuickPath: wgQuickPath || null, wgQuickPath: wgQuickPath || null,
wireguardGoPath: wireguardGoPath || null, wireguardGoPath: wireguardGoPath || null,
helperPath, helperPath,
helperSecurity,
dependenciesReady: Boolean(wgPath && wgQuickPath && wireguardGoPath), dependenciesReady: Boolean(wgPath && wgQuickPath && wireguardGoPath),
sudoReady: sudoProbe.ok, sudoReady: sudoProbe.ok,
interfaceName: MAC_WG_INTERFACE, interfaceName: helperStatus && helperStatus.interfaceName ? helperStatus.interfaceName : MAC_WG_INTERFACE,
configPath: fileExists(WG_CONFIG_PATH) ? WG_CONFIG_PATH : null, configPath: (helperStatus && helperStatus.configPath) || (fileExists(WG_CONFIG_PATH) ? WG_CONFIG_PATH : null),
active, active,
endpoint: endpoint || null, endpoint: endpoint || null,
showRaw, showRaw,
@@ -716,6 +1263,7 @@ function buildStateSummary(installProbe, ipInfo) {
wgQuickPath: installProbe.wireguard.wgQuickPath, wgQuickPath: installProbe.wireguard.wgQuickPath,
wireguardGoPath: installProbe.wireguard.wireguardGoPath, wireguardGoPath: installProbe.wireguard.wireguardGoPath,
helperPath: installProbe.wireguard.helperPath, helperPath: installProbe.wireguard.helperPath,
helperSecurity: installProbe.wireguard.helperSecurity,
authCache: installProbe.wireguard.authCache, authCache: installProbe.wireguard.authCache,
lastConnection: installProbe.wireguard.lastConnection, lastConnection: installProbe.wireguard.lastConnection,
} }
@@ -996,7 +1544,6 @@ function buildWireguardConfig(server, privateKey) {
"[Interface]", "[Interface]",
`PrivateKey = ${privateKey}`, `PrivateKey = ${privateKey}`,
`Address = ${addresses.join(", ")}`, `Address = ${addresses.join(", ")}`,
`DNS = ${NORDVPN_MAC_DNS_SERVERS.join(", ")}`,
"", "",
"[Peer]", "[Peer]",
`PublicKey = ${publicKey}`, `PublicKey = ${publicKey}`,
@@ -1123,10 +1670,7 @@ async function connectViaMacWireguard(installProbe, target) {
const down = await runSudoWireguard(installProbe, "down"); const down = await runSudoWireguard(installProbe, "down");
if (!down.ok) { if (!down.ok) {
const message = `${down.stderr || down.stdout || down.error}`.toLowerCase(); // Ignore the common no-active-interface case before reconnecting.
if (!message.includes("is not a known interface") && !message.includes("unable to access interface") && !message.includes("not found")) {
// Ignore only the common no-active-interface case.
}
} }
const up = await runSudoWireguard(installProbe, "up"); const up = await runSudoWireguard(installProbe, "up");
@@ -1137,23 +1681,6 @@ async function connectViaMacWireguard(installProbe, target) {
throw new Error((up.stderr || up.stdout || up.error).trim() || "wg-quick up failed"); throw new Error((up.stderr || up.stdout || up.error).trim() || "wg-quick up failed");
} }
writeJsonFile(LAST_CONNECTION_PATH, {
backend: "wireguard",
interfaceName: MAC_WG_INTERFACE,
requestedTarget: target,
resolvedTarget: {
country: targetMeta.country.name,
city: targetMeta.city ? targetMeta.city.name : getServerCityName(selectedServer),
},
server: {
hostname: selectedServer.hostname,
city: getServerCityName(selectedServer),
country: getServerCountryName(selectedServer),
load: selectedServer.load,
},
connectedAt: new Date().toISOString(),
});
return { return {
backend: "wireguard", backend: "wireguard",
server: { server: {
@@ -1193,10 +1720,12 @@ async function disconnectNordvpn(installProbe) {
if (installProbe.platform === "darwin" && installProbe.wireguard && installProbe.wireguard.dependenciesReady) { if (installProbe.platform === "darwin" && installProbe.wireguard && installProbe.wireguard.dependenciesReady) {
if (!shouldAttemptMacWireguardDisconnect(installProbe.wireguard)) { if (!shouldAttemptMacWireguardDisconnect(installProbe.wireguard)) {
const dnsState = await restoreMacDnsIfNeeded();
const tailscale = await resumeMacTailscaleIfNeeded(); const tailscale = await resumeMacTailscaleIfNeeded();
return { return {
backend: "wireguard", backend: "wireguard",
changed: false, changed: false,
dnsRestored: dnsState.restored,
tailscaleRestored: tailscale.restored, tailscaleRestored: tailscale.restored,
message: "No active macOS WireGuard NordVPN connection found.", message: "No active macOS WireGuard NordVPN connection found.",
}; };
@@ -1204,30 +1733,29 @@ async function disconnectNordvpn(installProbe) {
const down = await runSudoWireguard(installProbe, "down"); const down = await runSudoWireguard(installProbe, "down");
if (!down.ok) { if (!down.ok) {
const message = (down.stderr || down.stdout || down.error).trim(); const message = (down.stderr || down.stdout || down.error).trim();
const normalized = message.toLowerCase(); if (isBenignMacWireguardAbsentError(message)) {
if ( const dnsState = await restoreMacDnsIfNeeded();
normalized.includes("is not a known interface") || const cleaned = cleanupMacWireguardAndDnsState();
normalized.includes("unable to access interface") ||
normalized.includes("not found")
) {
const cleaned = cleanupMacWireguardState();
const tailscale = await resumeMacTailscaleIfNeeded(); const tailscale = await resumeMacTailscaleIfNeeded();
return { return {
backend: "wireguard", backend: "wireguard",
changed: false, changed: false,
stateCleaned: cleaned.cleaned, stateCleaned: cleaned.cleaned,
dnsRestored: dnsState.restored,
tailscaleRestored: tailscale.restored, tailscaleRestored: tailscale.restored,
message: "No active macOS WireGuard NordVPN connection found.", message: "No active macOS WireGuard NordVPN connection found.",
}; };
} }
throw new Error(message || "wg-quick down failed"); throw new Error(message || "wg-quick down failed");
} }
const cleaned = cleanupMacWireguardState(); const dnsState = await restoreMacDnsIfNeeded();
const cleaned = cleanupMacWireguardAndDnsState();
const tailscale = await resumeMacTailscaleIfNeeded(); const tailscale = await resumeMacTailscaleIfNeeded();
return { return {
backend: "wireguard", backend: "wireguard",
changed: true, changed: true,
stateCleaned: cleaned.cleaned, stateCleaned: cleaned.cleaned,
dnsRestored: dnsState.restored,
tailscaleRestored: tailscale.restored, tailscaleRestored: tailscale.restored,
message: "Disconnected the macOS NordLynx/WireGuard session.", message: "Disconnected the macOS NordLynx/WireGuard session.",
}; };
@@ -1367,8 +1895,12 @@ async function main() {
} }
if (action === "connect") { if (action === "connect") {
const lock = acquireOperationLock(action);
try {
const target = buildConnectTarget(args); const target = buildConnectTarget(args);
let connectResult; let connectResult;
let verified;
let persistence = null;
if (installProbe.cliPath) { if (installProbe.cliPath) {
connectResult = await connectViaCli(installProbe.cliPath, target); connectResult = await connectViaCli(installProbe.cliPath, target);
@@ -1387,17 +1919,175 @@ async function main() {
} }
if (connectResult.manualActionRequired) { if (connectResult.manualActionRequired) {
lock.release();
emitJson({ action, requestedTarget: target, ...connectResult }); emitJson({ action, requestedTarget: target, ...connectResult });
} }
const verified = await verifyConnectionWithRetry(target, { attempts: 6, delayMs: 2000 }); if (connectResult && connectResult.backend === "wireguard" && platform === "darwin") {
persistence = await checkMacWireguardPersistence(target, {
attempts: CONNECT_PERSISTENCE_ATTEMPTS,
delayMs: CONNECT_PERSISTENCE_DELAY_MS,
getHelperStatus: async () => getMacWireguardHelperStatus(installProbe),
verifyConnection: verifyConnection,
});
verified = persistence.verified;
if (!persistence.stable) {
const refreshed = await probeInstallation(platform); const refreshed = await probeInstallation(platform);
const connectState = normalizeSuccessfulConnectState(buildStateSummary(refreshed, verified.ipInfo), connectResult, verified); const diagnostics = await collectMacWireguardDiagnostics({
interfaceName: connectResult.interfaceName || MAC_WG_INTERFACE,
wgPath: refreshed.wireguard && refreshed.wireguard.wgPath,
helperPath: refreshed.wireguard && refreshed.wireguard.helperPath,
helperSecurity: refreshed.wireguard && refreshed.wireguard.helperSecurity,
});
const rollback = await disconnectNordvpn(refreshed);
lock.release();
emitJson( emitJson(
{ {
action, action,
requestedTarget: target, requestedTarget: target,
connectResult, connectResult,
persistence,
verified: false,
verification: verified && verified.ipInfo ? verified.ipInfo : null,
diagnostics: debugOutput ? diagnostics : undefined,
diagnosticsSummary: summarizeMacWireguardDiagnostics(diagnostics),
rollback,
state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()),
},
1,
true
);
}
verified = await verifyConnection(target);
} else {
verified = await verifyConnectionWithRetry(target, {
attempts: CONNECT_PERSISTENCE_ATTEMPTS,
delayMs: CONNECT_PERSISTENCE_DELAY_MS,
});
}
const refreshed = await probeInstallation(platform);
if (!verified.ok && connectResult && connectResult.backend === "wireguard" && platform === "darwin") {
const diagnostics = await collectMacWireguardDiagnostics({
interfaceName: connectResult.interfaceName || MAC_WG_INTERFACE,
wgPath: refreshed.wireguard && refreshed.wireguard.wgPath,
helperPath: refreshed.wireguard && refreshed.wireguard.helperPath,
helperSecurity: refreshed.wireguard && refreshed.wireguard.helperSecurity,
});
const rollback = await disconnectNordvpn(refreshed);
lock.release();
emitJson(
{
action,
requestedTarget: target,
connectResult,
persistence,
verified: false,
verification: verified.ipInfo,
diagnostics: debugOutput ? diagnostics : undefined,
diagnosticsSummary: summarizeMacWireguardDiagnostics(diagnostics),
rollback,
state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()),
},
1,
true
);
}
if (platform === "darwin" && shouldFinalizeMacWireguardConnect(connectResult, verified, persistence)) {
try {
await snapshotMacDnsState();
const liveness = await verifyConnection(target);
if (!liveness.ok) {
const rollback = await disconnectNordvpn(await probeInstallation(platform));
lock.release();
emitJson(
{
action,
requestedTarget: target,
connectResult,
persistence,
verified: false,
verification: liveness.ipInfo,
rollback,
error: "Connected but failed the final liveness check before DNS finalization.",
state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()),
},
1,
true
);
}
verified = liveness;
await applyMacNordDns();
const dnsResolution = await verifySystemHostnameResolution();
if (!dnsResolution.ok) {
const rollback = await disconnectNordvpn(await probeInstallation(platform));
lock.release();
emitJson(
{
action,
requestedTarget: target,
connectResult,
persistence,
verified: false,
verification: verified.ipInfo,
dnsResolution,
rollback,
error: `Connected but system DNS resolution failed after DNS finalization: ${dnsResolution.error}`,
state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()),
},
1,
true
);
}
writeJsonFile(LAST_CONNECTION_PATH, {
backend: "wireguard",
interfaceName: MAC_WG_INTERFACE,
requestedTarget: connectResult.requestedTarget,
resolvedTarget: connectResult.resolvedTarget,
server: {
hostname: connectResult.server.hostname,
city: connectResult.server.city,
country: connectResult.server.country,
load: connectResult.server.load,
},
connectedAt: new Date().toISOString(),
});
} catch (error) {
const rollback = await disconnectNordvpn(await probeInstallation(platform));
lock.release();
emitJson(
{
action,
requestedTarget: target,
connectResult,
persistence,
verified: true,
verification: verified.ipInfo,
rollback,
error: `Connected but failed to finalize macOS DNS/state: ${error.message || String(error)}`,
state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()),
},
1,
true
);
}
}
const connectState = normalizeSuccessfulConnectState(
buildStateSummary(await probeInstallation(platform), verified.ipInfo),
connectResult,
verified
);
lock.release();
emitJson(
{
action,
requestedTarget: target,
connectResult,
persistence,
verified: verified.ok, verified: verified.ok,
verification: verified.ipInfo, verification: verified.ipInfo,
state: connectState, state: connectState,
@@ -1405,21 +2095,47 @@ async function main() {
verified.ok ? 0 : 1, verified.ok ? 0 : 1,
!verified.ok !verified.ok
); );
} finally {
lock.release();
}
} }
if (action === "disconnect") { if (action === "disconnect") {
const lock = acquireOperationLock(action);
try {
const result = await disconnectNordvpn(installProbe); const result = await disconnectNordvpn(installProbe);
const refreshed = await probeInstallation(platform); const refreshed = await probeInstallation(platform);
lock.release();
emitJson({ emitJson({
action, action,
...result, ...result,
state: buildStateSummary(refreshed, await getPublicIpInfo()), state: buildStateSummary(refreshed, await getPublicIpInfo()),
}); });
} finally {
lock.release();
}
} }
emitJson({ error: `Unknown action: ${action}`, ...usage() }, 1, true); emitJson({ error: `Unknown action: ${action}`, ...usage() }, 1, true);
} catch (error) { } catch (error) {
emitJson({ error: error.message || String(error), action }, 1, true); const payload = { error: error.message || String(error), action };
if (action === "connect" && platform === "darwin" && installProbe && installProbe.wireguard) {
try {
const refreshed = await probeInstallation(platform);
const diagnostics = await collectMacWireguardDiagnostics({
interfaceName: MAC_WG_INTERFACE,
wgPath: refreshed.wireguard && refreshed.wireguard.wgPath,
helperPath: refreshed.wireguard && refreshed.wireguard.helperPath,
helperSecurity: refreshed.wireguard && refreshed.wireguard.helperSecurity,
});
payload.state = buildStateSummary(refreshed, await getPublicIpInfo());
payload.diagnostics = debugOutput ? diagnostics : undefined;
payload.diagnosticsSummary = summarizeMacWireguardDiagnostics(diagnostics);
} catch {
// Fall back to the base error payload if diagnostic capture also fails.
}
}
emitJson(payload, 1, true);
} }
} }

View File

@@ -11,28 +11,58 @@ function loadInternals() {
module.exports = { module.exports = {
buildMacTailscaleState: buildMacTailscaleState:
typeof buildMacTailscaleState === "function" ? buildMacTailscaleState : undefined, typeof buildMacTailscaleState === "function" ? buildMacTailscaleState : undefined,
markMacTailscaleRecoverySuppressed:
typeof markMacTailscaleRecoverySuppressed === "function" ? markMacTailscaleRecoverySuppressed : undefined,
clearMacTailscaleRecoverySuppressed:
typeof clearMacTailscaleRecoverySuppressed === "function" ? clearMacTailscaleRecoverySuppressed : undefined,
buildMacDnsState:
typeof buildMacDnsState === "function" ? buildMacDnsState : undefined,
buildWireguardConfig: buildWireguardConfig:
typeof buildWireguardConfig === "function" ? buildWireguardConfig : undefined, typeof buildWireguardConfig === "function" ? buildWireguardConfig : undefined,
buildLookupResult: buildLookupResult:
typeof buildLookupResult === "function" ? buildLookupResult : undefined, typeof buildLookupResult === "function" ? buildLookupResult : undefined,
cleanupMacWireguardState: cleanupMacWireguardState:
typeof cleanupMacWireguardState === "function" ? cleanupMacWireguardState : undefined, typeof cleanupMacWireguardState === "function" ? cleanupMacWireguardState : undefined,
cleanupMacWireguardAndDnsState:
typeof cleanupMacWireguardAndDnsState === "function" ? cleanupMacWireguardAndDnsState : undefined,
collectMacWireguardDiagnostics:
typeof collectMacWireguardDiagnostics === "function" ? collectMacWireguardDiagnostics : undefined,
acquireOperationLock:
typeof acquireOperationLock === "function" ? acquireOperationLock : undefined,
inspectMacWireguardHelperSecurity:
typeof inspectMacWireguardHelperSecurity === "function" ? inspectMacWireguardHelperSecurity : undefined,
getMacTailscalePath: getMacTailscalePath:
typeof getMacTailscalePath === "function" ? getMacTailscalePath : undefined, typeof getMacTailscalePath === "function" ? getMacTailscalePath : undefined,
isBenignMacWireguardAbsentError:
typeof isBenignMacWireguardAbsentError === "function" ? isBenignMacWireguardAbsentError : undefined,
isMacTailscaleActive: isMacTailscaleActive:
typeof isMacTailscaleActive === "function" ? isMacTailscaleActive : undefined, typeof isMacTailscaleActive === "function" ? isMacTailscaleActive : undefined,
checkMacWireguardPersistence:
typeof checkMacWireguardPersistence === "function" ? checkMacWireguardPersistence : undefined,
normalizeSuccessfulConnectState: normalizeSuccessfulConnectState:
typeof normalizeSuccessfulConnectState === "function" ? normalizeSuccessfulConnectState : undefined, typeof normalizeSuccessfulConnectState === "function" ? normalizeSuccessfulConnectState : undefined,
normalizeStatusState: normalizeStatusState:
typeof normalizeStatusState === "function" ? normalizeStatusState : undefined, typeof normalizeStatusState === "function" ? normalizeStatusState : undefined,
parseMacWireguardHelperStatus:
typeof parseMacWireguardHelperStatus === "function" ? parseMacWireguardHelperStatus : undefined,
shouldRejectMacDnsBaseline:
typeof shouldRejectMacDnsBaseline === "function" ? shouldRejectMacDnsBaseline : undefined,
shouldManageMacDnsService:
typeof shouldManageMacDnsService === "function" ? shouldManageMacDnsService : undefined,
sanitizeOutputPayload: sanitizeOutputPayload:
typeof sanitizeOutputPayload === "function" ? sanitizeOutputPayload : undefined, typeof sanitizeOutputPayload === "function" ? sanitizeOutputPayload : undefined,
shouldFinalizeMacWireguardConnect:
typeof shouldFinalizeMacWireguardConnect === "function" ? shouldFinalizeMacWireguardConnect : undefined,
shouldResumeMacTailscale:
typeof shouldResumeMacTailscale === "function" ? shouldResumeMacTailscale : undefined,
shouldAttemptMacWireguardDisconnect: shouldAttemptMacWireguardDisconnect:
typeof shouldAttemptMacWireguardDisconnect === "function" ? shouldAttemptMacWireguardDisconnect : undefined, typeof shouldAttemptMacWireguardDisconnect === "function" ? shouldAttemptMacWireguardDisconnect : undefined,
detectMacWireguardActiveFromIfconfig: detectMacWireguardActiveFromIfconfig:
typeof detectMacWireguardActiveFromIfconfig === "function" ? detectMacWireguardActiveFromIfconfig : undefined, typeof detectMacWireguardActiveFromIfconfig === "function" ? detectMacWireguardActiveFromIfconfig : undefined,
resolveHostnameWithFallback: resolveHostnameWithFallback:
typeof resolveHostnameWithFallback === "function" ? resolveHostnameWithFallback : undefined, typeof resolveHostnameWithFallback === "function" ? resolveHostnameWithFallback : undefined,
verifySystemHostnameResolution:
typeof verifySystemHostnameResolution === "function" ? verifySystemHostnameResolution : undefined,
verifyConnectionWithRetry: verifyConnectionWithRetry:
typeof verifyConnectionWithRetry === "function" ? verifyConnectionWithRetry : undefined, typeof verifyConnectionWithRetry === "function" ? verifyConnectionWithRetry : undefined,
};`; };`;
@@ -78,7 +108,7 @@ test("buildLookupResult supports lookup all=true mode", () => {
assert.equal(JSON.stringify(buildLookupResult("104.26.9.44", { all: false })), JSON.stringify(["104.26.9.44", 4])); assert.equal(JSON.stringify(buildLookupResult("104.26.9.44", { all: false })), JSON.stringify(["104.26.9.44", 4]));
}); });
test("buildWireguardConfig includes NordVPN DNS for the vanilla macOS config path", () => { test("buildWireguardConfig omits DNS and relies on post-connect networksetup on macOS", () => {
const { buildWireguardConfig } = loadInternals(); const { buildWireguardConfig } = loadInternals();
assert.equal(typeof buildWireguardConfig, "function"); assert.equal(typeof buildWireguardConfig, "function");
@@ -91,10 +121,71 @@ test("buildWireguardConfig includes NordVPN DNS for the vanilla macOS config pat
"PRIVATEKEY" "PRIVATEKEY"
); );
assert.equal(config.includes("DNS = 103.86.96.100, 103.86.99.100"), true); assert.equal(config.includes("DNS = 103.86.96.100, 103.86.99.100"), false);
assert.equal(config.includes("AllowedIPs = 0.0.0.0/0"), true); assert.equal(config.includes("AllowedIPs = 0.0.0.0/0"), true);
}); });
test("shouldManageMacDnsService keeps active physical services and excludes virtual ones", () => {
const { shouldManageMacDnsService } = loadInternals();
assert.equal(typeof shouldManageMacDnsService, "function");
assert.equal(shouldManageMacDnsService("Wi-Fi"), true);
assert.equal(shouldManageMacDnsService("USB 10/100/1000 LAN"), true);
assert.equal(shouldManageMacDnsService("Tailscale"), false);
assert.equal(shouldManageMacDnsService("Thunderbolt Bridge"), false);
assert.equal(shouldManageMacDnsService("Acme VPN"), false);
});
test("buildMacDnsState records DNS and search domains per service", () => {
const { buildMacDnsState } = loadInternals();
assert.equal(typeof buildMacDnsState, "function");
assert.equal(
JSON.stringify(
buildMacDnsState([
{ name: "Wi-Fi", dnsServers: ["1.1.1.1"], searchDomains: [] },
{ name: "USB 10/100/1000 LAN", dnsServers: [], searchDomains: ["lan"] },
])
),
JSON.stringify({
services: [
{ name: "Wi-Fi", dnsServers: ["1.1.1.1"], searchDomains: [] },
{ name: "USB 10/100/1000 LAN", dnsServers: [], searchDomains: ["lan"] },
],
})
);
});
test("shouldRejectMacDnsBaseline flags a NordVPN-pinned restore snapshot", () => {
const { shouldRejectMacDnsBaseline } = loadInternals();
assert.equal(typeof shouldRejectMacDnsBaseline, "function");
assert.equal(
shouldRejectMacDnsBaseline({
services: [
{
name: "Wi-Fi",
dnsServers: ["103.86.96.100", "103.86.99.100"],
searchDomains: [],
},
],
}),
true
);
assert.equal(
shouldRejectMacDnsBaseline({
services: [
{
name: "Wi-Fi",
dnsServers: ["1.1.1.1"],
searchDomains: [],
},
],
}),
false
);
});
test("getMacTailscalePath falls back to /opt/homebrew/bin/tailscale when PATH lookup is missing", () => { test("getMacTailscalePath falls back to /opt/homebrew/bin/tailscale when PATH lookup is missing", () => {
const { getMacTailscalePath } = loadInternals(); const { getMacTailscalePath } = loadInternals();
assert.equal(typeof getMacTailscalePath, "function"); assert.equal(typeof getMacTailscalePath, "function");
@@ -118,6 +209,29 @@ test("buildMacTailscaleState records whether tailscale was active", () => {
); );
}); });
test("tailscale recovery suppression marker can be created and cleared", () => {
const { markMacTailscaleRecoverySuppressed, clearMacTailscaleRecoverySuppressed } = loadInternals();
assert.equal(typeof markMacTailscaleRecoverySuppressed, "function");
assert.equal(typeof clearMacTailscaleRecoverySuppressed, "function");
const markerPath = path.join(process.env.HOME || "", ".nordvpn-client", "tailscale-suppressed");
clearMacTailscaleRecoverySuppressed();
markMacTailscaleRecoverySuppressed();
assert.equal(fs.existsSync(markerPath), true);
clearMacTailscaleRecoverySuppressed();
assert.equal(fs.existsSync(markerPath), false);
});
test("shouldResumeMacTailscale only resumes when previously active and not already running", () => {
const { shouldResumeMacTailscale } = loadInternals();
assert.equal(typeof shouldResumeMacTailscale, "function");
assert.equal(shouldResumeMacTailscale({ tailscaleWasActive: true }, false), true);
assert.equal(shouldResumeMacTailscale({ tailscaleWasActive: true }, true), false);
assert.equal(shouldResumeMacTailscale({ tailscaleWasActive: false }, false), false);
assert.equal(shouldResumeMacTailscale(null, false), false);
});
test("cleanupMacWireguardState removes stale config and last-connection files", () => { test("cleanupMacWireguardState removes stale config and last-connection files", () => {
const { cleanupMacWireguardState } = loadInternals(); const { cleanupMacWireguardState } = loadInternals();
assert.equal(typeof cleanupMacWireguardState, "function"); assert.equal(typeof cleanupMacWireguardState, "function");
@@ -138,6 +252,190 @@ test("cleanupMacWireguardState removes stale config and last-connection files",
assert.equal(fs.existsSync(lastConnectionPath), false); assert.equal(fs.existsSync(lastConnectionPath), false);
}); });
test("cleanupMacWireguardAndDnsState removes stale config, DNS snapshot, and last-connection files", () => {
const { cleanupMacWireguardAndDnsState } = loadInternals();
assert.equal(typeof cleanupMacWireguardAndDnsState, "function");
const tmpDir = fs.mkdtempSync(path.join(fs.mkdtempSync("/tmp/nordvpn-client-test-"), "state-"));
const configPath = path.join(tmpDir, "nordvpnctl.conf");
const lastConnectionPath = path.join(tmpDir, "last-connection.json");
const dnsStatePath = path.join(tmpDir, "dns.json");
fs.writeFileSync(configPath, "wireguard-config");
fs.writeFileSync(lastConnectionPath, "{\"country\":\"Germany\"}");
fs.writeFileSync(dnsStatePath, "{\"services\":[]}");
const result = cleanupMacWireguardAndDnsState({
configPath,
lastConnectionPath,
dnsStatePath,
});
assert.equal(result.cleaned, true);
assert.equal(fs.existsSync(configPath), false);
assert.equal(fs.existsSync(lastConnectionPath), false);
assert.equal(fs.existsSync(dnsStatePath), false);
});
test("inspectMacWireguardHelperSecurity rejects a user-owned helper path", () => {
const { inspectMacWireguardHelperSecurity } = loadInternals();
assert.equal(typeof inspectMacWireguardHelperSecurity, "function");
const result = inspectMacWireguardHelperSecurity("/tmp/nordvpn-wireguard-helper.sh", {
fileExists: () => true,
statSync: () => ({
uid: 501,
gid: 20,
mode: 0o100755,
}),
});
assert.equal(result.exists, true);
assert.equal(result.hardened, false);
assert.match(result.reason, /root-owned/i);
});
test("inspectMacWireguardHelperSecurity accepts a root-owned non-writable helper path", () => {
const { inspectMacWireguardHelperSecurity } = loadInternals();
assert.equal(typeof inspectMacWireguardHelperSecurity, "function");
const result = inspectMacWireguardHelperSecurity("/tmp/nordvpn-wireguard-helper.sh", {
fileExists: () => true,
statSync: () => ({
uid: 0,
gid: 0,
mode: 0o100755,
}),
});
assert.equal(result.exists, true);
assert.equal(result.hardened, true);
assert.equal(result.reason, "");
});
test("collectMacWireguardDiagnostics captures bounded wg/ifconfig/route/process output", async () => {
const { collectMacWireguardDiagnostics } = loadInternals();
assert.equal(typeof collectMacWireguardDiagnostics, "function");
const seen = [];
const result = await collectMacWireguardDiagnostics({
interfaceName: "nordvpnctl",
runExec: async (command, args) => {
seen.push(`${command} ${args.join(" ")}`);
if (command === "/opt/homebrew/bin/wg") {
return { ok: true, stdout: "interface: nordvpnctl\npeer: abc123", stderr: "", error: "" };
}
if (command === "ifconfig") {
return { ok: true, stdout: "utun8: flags=8051\n\tinet 10.5.0.2 --> 10.5.0.2", stderr: "", error: "" };
}
if (command === "route") {
return { ok: true, stdout: "default 10.5.0.2 UGSc", stderr: "", error: "" };
}
if (command === "pgrep") {
return { ok: true, stdout: "1234 wireguard-go utun\n5678 wg-quick up nordvpnctl", stderr: "", error: "" };
}
throw new Error(`unexpected command: ${command}`);
},
});
assert.deepEqual(seen, [
"/opt/homebrew/bin/wg show nordvpnctl",
"ifconfig nordvpnctl",
"route -n get default",
"pgrep -fl wireguard-go|wg-quick|nordvpnctl",
]);
assert.equal(result.interfaceName, "nordvpnctl");
assert.equal(result.wgShow.includes("peer: abc123"), true);
assert.equal(result.ifconfig.includes("10.5.0.2"), true);
assert.equal(result.routes.includes("default 10.5.0.2"), true);
assert.equal(result.processes.includes("wireguard-go"), true);
});
test("parseMacWireguardHelperStatus reads active helper key-value output", () => {
const { parseMacWireguardHelperStatus } = loadInternals();
assert.equal(typeof parseMacWireguardHelperStatus, "function");
const result = parseMacWireguardHelperStatus("active=1\ninterfaceName=nordvpnctl\n");
assert.equal(result.active, true);
assert.equal(result.interfaceName, "nordvpnctl");
});
test("checkMacWireguardPersistence waits for both helper-active and verified exit", async () => {
const { checkMacWireguardPersistence } = loadInternals();
assert.equal(typeof checkMacWireguardPersistence, "function");
const helperStatuses = [
{ active: false, interfaceName: "nordvpnctl" },
{ active: true, interfaceName: "nordvpnctl" },
];
const verifications = [
{ ok: false, ipInfo: { ok: false, error: "timeout" } },
{ ok: true, ipInfo: { ok: true, country: "Germany", city: "Frankfurt" } },
];
const result = await checkMacWireguardPersistence(
{ country: "Germany", city: "" },
{
attempts: 2,
delayMs: 1,
getHelperStatus: async () => helperStatuses.shift(),
verifyConnection: async () => verifications.shift(),
sleep: async () => {},
}
);
assert.equal(result.stable, true);
assert.equal(result.attempts, 2);
assert.equal(result.helperStatus.active, true);
assert.equal(result.verified.ok, true);
});
test("checkMacWireguardPersistence returns the last failed status when stability is not reached", async () => {
const { checkMacWireguardPersistence } = loadInternals();
assert.equal(typeof checkMacWireguardPersistence, "function");
const result = await checkMacWireguardPersistence(
{ country: "Germany", city: "" },
{
attempts: 2,
delayMs: 1,
getHelperStatus: async () => ({ active: false, interfaceName: "nordvpnctl" }),
verifyConnection: async () => ({ ok: false, ipInfo: { ok: false, error: "timeout" } }),
sleep: async () => {},
}
);
assert.equal(result.stable, false);
assert.equal(result.attempts, 2);
assert.equal(result.helperStatus.active, false);
assert.equal(result.verified.ok, false);
});
test("acquireOperationLock cleans a stale dead-pid lock before taking ownership", () => {
const { acquireOperationLock } = loadInternals();
assert.equal(typeof acquireOperationLock, "function");
const tmpDir = fs.mkdtempSync(path.join(fs.mkdtempSync("/tmp/nordvpn-client-test-"), "lock-"));
const lockPath = path.join(tmpDir, "operation.lock");
fs.writeFileSync(
lockPath,
JSON.stringify({
action: "connect",
pid: 0,
startedAt: new Date(0).toISOString(),
startedAtMs: 0,
})
);
const lock = acquireOperationLock("disconnect", { lockPath });
const lockFile = JSON.parse(fs.readFileSync(lockPath, "utf8"));
assert.equal(lockFile.action, "disconnect");
assert.equal(lockFile.pid, process.pid);
lock.release();
assert.equal(fs.existsSync(lockPath), false);
});
test("shouldAttemptMacWireguardDisconnect does not trust active=false when residual state exists", () => { test("shouldAttemptMacWireguardDisconnect does not trust active=false when residual state exists", () => {
const { shouldAttemptMacWireguardDisconnect } = loadInternals(); const { shouldAttemptMacWireguardDisconnect } = loadInternals();
assert.equal(typeof shouldAttemptMacWireguardDisconnect, "function"); assert.equal(typeof shouldAttemptMacWireguardDisconnect, "function");
@@ -173,6 +471,15 @@ test("shouldAttemptMacWireguardDisconnect does not trust active=false when resid
); );
}); });
test("isBenignMacWireguardAbsentError recognizes stale-interface teardown errors", () => {
const { isBenignMacWireguardAbsentError } = loadInternals();
assert.equal(typeof isBenignMacWireguardAbsentError, "function");
assert.equal(isBenignMacWireguardAbsentError("wg-quick: `nordvpnctl' is not a WireGuard interface"), true);
assert.equal(isBenignMacWireguardAbsentError("Unable to access interface: No such file or directory"), true);
assert.equal(isBenignMacWireguardAbsentError("permission denied"), false);
});
test("normalizeSuccessfulConnectState marks the connect snapshot active after verified macOS wireguard connect", () => { test("normalizeSuccessfulConnectState marks the connect snapshot active after verified macOS wireguard connect", () => {
const { normalizeSuccessfulConnectState } = loadInternals(); const { normalizeSuccessfulConnectState } = loadInternals();
assert.equal(typeof normalizeSuccessfulConnectState, "function"); assert.equal(typeof normalizeSuccessfulConnectState, "function");
@@ -206,6 +513,17 @@ test("normalizeSuccessfulConnectState marks the connect snapshot active after ve
assert.equal(state.wireguard.endpoint, "de1227.nordvpn.com:51820"); assert.equal(state.wireguard.endpoint, "de1227.nordvpn.com:51820");
}); });
test("shouldFinalizeMacWireguardConnect requires a verified and stable wireguard connect", () => {
const { shouldFinalizeMacWireguardConnect } = loadInternals();
assert.equal(typeof shouldFinalizeMacWireguardConnect, "function");
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: true }, { stable: true }), true);
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: true }, { stable: false }), false);
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: false }, { stable: true }), false);
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "cli" }, { ok: true }, { stable: true }), false);
assert.equal(shouldFinalizeMacWireguardConnect(null, { ok: true }, { stable: true }), false);
});
test("normalizeStatusState marks macOS wireguard connected when public IP matches the last successful target", () => { test("normalizeStatusState marks macOS wireguard connected when public IP matches the last successful target", () => {
const { normalizeStatusState } = loadInternals(); const { normalizeStatusState } = loadInternals();
assert.equal(typeof normalizeStatusState, "function"); assert.equal(typeof normalizeStatusState, "function");
@@ -243,6 +561,10 @@ test("sanitizeOutputPayload redacts local path metadata from normal JSON output"
wireguard: { wireguard: {
configPath: "/Users/stefano/.nordvpn-client/wireguard/nordvpnctl.conf", configPath: "/Users/stefano/.nordvpn-client/wireguard/nordvpnctl.conf",
helperPath: "/Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh", helperPath: "/Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh",
helperSecurity: {
hardened: false,
reason: "Helper must be root-owned before privileged actions are trusted.",
},
authCache: { authCache: {
tokenSource: "default:/Users/stefano/.openclaw/workspace/.clawdbot/credentials/nordvpn/token.txt", tokenSource: "default:/Users/stefano/.openclaw/workspace/.clawdbot/credentials/nordvpn/token.txt",
}, },
@@ -254,6 +576,7 @@ test("sanitizeOutputPayload redacts local path metadata from normal JSON output"
assert.equal(sanitized.appPath, null); assert.equal(sanitized.appPath, null);
assert.equal(sanitized.wireguard.configPath, null); assert.equal(sanitized.wireguard.configPath, null);
assert.equal(sanitized.wireguard.helperPath, null); assert.equal(sanitized.wireguard.helperPath, null);
assert.equal(sanitized.wireguard.helperSecurity, null);
assert.equal(sanitized.wireguard.authCache.tokenSource, null); assert.equal(sanitized.wireguard.authCache.tokenSource, null);
assert.equal(sanitized.wireguard.endpoint, "jp454.nordvpn.com:51820"); assert.equal(sanitized.wireguard.endpoint, "jp454.nordvpn.com:51820");
}); });
@@ -307,3 +630,42 @@ test("resolveHostnameWithFallback uses fallback resolvers when system lookup fai
assert.equal(address, "104.26.9.44"); assert.equal(address, "104.26.9.44");
assert.deepEqual(calls, ["1.1.1.1:ipapi.co", "8.8.8.8:ipapi.co"]); assert.deepEqual(calls, ["1.1.1.1:ipapi.co", "8.8.8.8:ipapi.co"]);
}); });
test("verifySystemHostnameResolution succeeds when any system lookup resolves", async () => {
const { verifySystemHostnameResolution } = loadInternals();
assert.equal(typeof verifySystemHostnameResolution, "function");
const calls = [];
const result = await verifySystemHostnameResolution(["www.google.com", "api.openai.com"], {
timeoutMs: 5,
lookup: async (hostname) => {
calls.push(hostname);
if (hostname === "www.google.com") {
throw new Error("ENOTFOUND");
}
return { address: "104.18.33.45", family: 4 };
},
});
assert.equal(result.ok, true);
assert.equal(result.hostname, "api.openai.com");
assert.equal(result.address, "104.18.33.45");
assert.deepEqual(calls, ["www.google.com", "api.openai.com"]);
});
test("verifySystemHostnameResolution fails when all hostnames fail system lookup", async () => {
const { verifySystemHostnameResolution } = loadInternals();
assert.equal(typeof verifySystemHostnameResolution, "function");
const result = await verifySystemHostnameResolution(["www.google.com", "api.openai.com"], {
timeoutMs: 5,
lookup: async (hostname) => {
throw new Error(`${hostname}: timeout`);
},
});
assert.equal(result.ok, false);
assert.equal(result.hostname, "");
assert.match(result.error, /www\.google\.com/);
assert.match(result.error, /api\.openai\.com/);
});

View File

@@ -3,21 +3,53 @@ set -eu
ACTION="${1:-}" ACTION="${1:-}"
case "$ACTION" in case "$ACTION" in
probe|up|down) probe|up|down|status|cleanup)
;; ;;
*) *)
echo "Usage: nordvpn-wireguard-helper.sh [probe|up|down]" >&2 echo "Usage: nordvpn-wireguard-helper.sh [probe|up|down|status|cleanup]" >&2
exit 2 exit 2
;; ;;
esac esac
WG_QUICK="/opt/homebrew/bin/wg-quick" WG_QUICK="/opt/homebrew/bin/wg-quick"
WG="/opt/homebrew/bin/wg"
WG_CONFIG="/Users/stefano/.nordvpn-client/wireguard/nordvpnctl.conf" WG_CONFIG="/Users/stefano/.nordvpn-client/wireguard/nordvpnctl.conf"
WG_INTERFACE="nordvpnctl"
PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH export PATH
if [ "$ACTION" = "probe" ]; then if [ "$ACTION" = "probe" ] || [ "$ACTION" = "status" ]; then
test -x "$WG_QUICK" test -x "$WG_QUICK"
ACTIVE=0
RUNTIME_INTERFACE=""
if [ -x "$WG" ]; then
RUNTIME_INTERFACE=$("$WG" show interfaces 2>/dev/null | awk 'NF { print $1; exit }')
fi
if [ -n "$RUNTIME_INTERFACE" ]; then
ACTIVE=1
elif [ -x "$WG" ] && "$WG" show "$WG_INTERFACE" >/dev/null 2>&1; then
ACTIVE=1
elif /sbin/ifconfig "$WG_INTERFACE" >/dev/null 2>&1; then
ACTIVE=1
elif pgrep -f "wg-quick up $WG_CONFIG" >/dev/null 2>&1; then
ACTIVE=1
elif pgrep -f "wireguard-go utun" >/dev/null 2>&1; then
ACTIVE=1
fi
echo "active=$ACTIVE"
echo "interfaceName=$WG_INTERFACE"
if [ -n "$RUNTIME_INTERFACE" ]; then
echo "wireguardInterface=$RUNTIME_INTERFACE"
fi
if [ -f "$WG_CONFIG" ]; then
echo "configPath=$WG_CONFIG"
fi
exit 0
fi
if [ "$ACTION" = "cleanup" ]; then
"$WG_QUICK" down "$WG_CONFIG" >/dev/null 2>&1 || true
exit 0 exit 0
fi fi