pinggy 0.6.1 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +334 -10
- package/CLAUDE.md +20 -2
- package/README.md +90 -0
- package/dist/TunnelManager-NGSUBZYI.js +13 -0
- package/dist/{TunnelTui-GBRJDOQD.js → TunnelTui-UCG2YSLX.js} +6 -18
- package/dist/{chunk-I4HXBP5G.js → chunk-7EQKHVQX.js} +34 -2
- package/dist/{chunk-GBYF2H4H.js → chunk-AFMTIELL.js} +4 -0
- package/dist/{chunk-QVV3MRKY.js → chunk-GESMZQHI.js} +47 -119
- package/dist/{chunk-JQAQRJPE.js → chunk-MV56U3E6.js} +2 -2
- package/dist/{chunk-MT44NAXX.js → chunk-PD7HXVQ3.js} +1 -1
- package/dist/{chunk-LV3J6TAV.js → chunk-QNUVQ6KD.js} +199 -17
- package/dist/{chunk-7F67A6LY.js → chunk-TG6LBCPG.js} +3486 -236
- package/dist/chunk-XZ64ZAGO.js +18 -0
- package/dist/{chunk-DJ5WX37D.js → chunk-Y435RMHQ.js} +7 -3
- package/dist/{configStore-XRWOTTA4.js → configStore-RR5ZU4E3.js} +3 -3
- package/dist/{daemonChild-VX7SCGVS.js → daemonChild-E73NFWKE.js} +13 -8
- package/dist/{daemonConfig-G6S46GPJ.js → daemonConfig-QVXQ47XH.js} +2 -2
- package/dist/index.cjs +3919 -277
- package/dist/index.d.cts +135 -53
- package/dist/index.d.ts +135 -53
- package/dist/index.js +13 -10
- package/dist/ipcClient-XGF2KCQO.js +10 -0
- package/dist/{main-GXPUTY6N.js → main-M6MACH7D.js} +9 -8
- package/dist/workers/file_serve_worker.js +1 -1
- package/package.json +5 -2
- package/scripts/install/install.ps1 +261 -0
- package/scripts/install/install.sh +387 -0
- package/dist/TunnelManager-EHMPIQDZ.js +0 -11
- package/dist/ipcClient-URHJKNEH.js +0 -6
package/ARCHITECTURE.md
CHANGED
|
@@ -11,11 +11,13 @@ The `pinggy` binary has three execution modes, dispatched in `src/main.ts`:
|
|
|
11
11
|
| Mode | Trigger | Entry | Purpose |
|
|
12
12
|
| -------------------- | -------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
13
13
|
| Daemon child | `--_daemon-child` flag | `runDaemonChild()` in `src/daemon/daemonChild.ts` | Long-running background process. Owns the `TunnelManager` and the SDK. |
|
|
14
|
-
| Subcommand | First arg in `SUBCOMMANDS` set | `handleSubcommand()` in `src/cli/subcommands.ts` | Short-lived CLI invocation:`config`, `start`, `stop`, `ps`, `attach`, `daemon`, `logs`, `log`, `restart
|
|
14
|
+
| Subcommand | First arg in `SUBCOMMANDS` set | `handleSubcommand()` in `src/cli/subcommands.ts` | Short-lived CLI invocation:`config`, `start`, `stop`, `ps`, `attach`, `daemon`, `logs`, `log`, `restart`, `device`, and `devices`, which prints the new spelling. |
|
|
15
15
|
| Legacy single-tunnel | No subcommand, has flags | `buildAndStartTunnel()` in `src/cli/buildAndStartTunnel.ts` | Backwards-compatible single-shot tunnel for flags like `-l 3000` or `-R0:...`. |
|
|
16
16
|
|
|
17
17
|
The CLI never opens an SDK tunnel itself. Every tunnel is created inside the daemon. CLI processes talk to the daemon over HTTP + WebSocket on `127.0.0.1`.
|
|
18
18
|
|
|
19
|
+
`pinggy device` is the exception. In the foreground it runs the device agent inside the CLI process and talks to the dashboard directly. With `-b` (slice 17) the daemon runs the same agent instead, so it outlives the terminal. It reads the daemon's tunnel list, and since slice 10 it stops, restarts and starts tunnels when the dashboard asks. See section 17.
|
|
20
|
+
|
|
19
21
|

|
|
20
22
|
|
|
21
23
|
A given user has **one** daemon. Whichever client (CLI or App) finds no live daemon spawns one; later clients discover it via `daemon.json`.
|
|
@@ -37,7 +39,8 @@ Logs live under `getPinggyLogDir()`:
|
|
|
37
39
|
```
|
|
38
40
|
<configDir>/
|
|
39
41
|
├── daemon.json Daemon discovery: {pid, port, startedAt}
|
|
40
|
-
├── daemon-state.json Active
|
|
42
|
+
├── daemon-state.json Active tunnels and the background device agent, for crash recovery
|
|
43
|
+
├── device.json Device agent identity + token, mode 0600
|
|
41
44
|
└── tunnels/
|
|
42
45
|
└── <name>_<configId>.json Saved tunnel configs (from `pinggy config save`)
|
|
43
46
|
|
|
@@ -47,7 +50,7 @@ Logs live under `getPinggyLogDir()`:
|
|
|
47
50
|
└── <origin>__<name>__<tunnelId>.log Per-tunnel logs (rolled 10MB×3)
|
|
48
51
|
```
|
|
49
52
|
|
|
50
|
-
Helpers live in `src/utils/configDir.ts`: `getDaemonInfoPath`, `getDaemonLogPath`, `getTunnelLogPath`, `getTunnelConfigDir`.
|
|
53
|
+
Helpers live in `src/utils/configDir.ts`: `getDaemonInfoPath`, `getDaemonLogPath`, `getTunnelLogPath`, `getTunnelConfigDir`, `getDeviceConfigPath`.
|
|
51
54
|
|
|
52
55
|
## 3. Daemon discovery and spawn
|
|
53
56
|
|
|
@@ -140,10 +143,15 @@ Server: `src/daemon/ipcServer.ts`. Client: `src/daemon/ipcClient.ts`. Public fac
|
|
|
140
143
|
| GET | `/logs/paths` | All tunnel log file paths + running flags |
|
|
141
144
|
| GET | `/logs/resolve?q=` | Resolve a name/id/configId to a log path |
|
|
142
145
|
| POST | `/shutdown` | Graceful shutdown |
|
|
146
|
+
| GET | `/device` | The background device agent's status (slice 17) |
|
|
147
|
+
| POST | `/device/start` | Start the device agent on `device.json` |
|
|
148
|
+
| POST | `/device/stop` | Stop the device agent |
|
|
143
149
|
|
|
144
150
|
Conventions:
|
|
145
151
|
|
|
146
|
-
-
|
|
152
|
+
- The 3 `/device` routes read no request field. The agent's token and server come from `device.json`, never from IPC: any local user can reach this port, and a route that took a token would let another user point this user's shell at their own account. `DeviceAgentStatus` never carries the token. See section 17, Background mode.
|
|
153
|
+
|
|
154
|
+
- Every request sets `X-Pinggy-Origin: app|cli|remote|device` (defaulted in `parseOrigin()` to `cli`). Stored on the tunnel and used in log filenames.
|
|
147
155
|
- V2 is the canonical shape. V1 routes (`/tunnels-v1`, `/tunnels/start-v1`, `/tunnels/update-config`) exist for remote management payloads that still use the older schema. New code should target V2.
|
|
148
156
|
- `start` is idempotent on `configId`. If a tunnel with the same `configId` is already running, the daemon returns `ErrorResponse{code: TunnelAlreadyRunningError}` and the CLI prints the existing state instead of starting a duplicate.
|
|
149
157
|
- A successful operation returns its typed response with HTTP 200. An expected application-level failure returns `ErrorResponse` JSON, still with HTTP 200; the route handled the request, the operation just failed. Non-200 means a transport-level problem (daemon died mid-request, port in use after spawn, etc.) and surfaces as a thrown error in `IPCClient`.
|
|
@@ -249,14 +257,15 @@ Key invariants:
|
|
|
249
257
|
```ts
|
|
250
258
|
interface DaemonState {
|
|
251
259
|
tunnels: DaemonStateTunnel[]; // {tunnelId, configId, name, origin, config, mode, startedAt}
|
|
260
|
+
deviceAgent?: { startedAt: string; startedBy: TunnelOrigin }; // slice 17, absent when none runs
|
|
252
261
|
lastUpdated: string;
|
|
253
262
|
}
|
|
254
263
|
```
|
|
255
264
|
|
|
256
265
|
Two lifecycle paths:
|
|
257
266
|
|
|
258
|
-
- **Clean shutdown** (`/shutdown` or signal): `clearDaemonState()` deletes the file. Next daemon start sees nothing to recover.
|
|
259
|
-
- **Crash** (SIGKILL, OOM, panic): the file remains. Next daemon start calls `restoreCrashedTunnels()`, which restarts each `mode === "detached"` tunnel using the persisted config and stable `tunnelId` (so log files stay continuous). Foreground tunnels are not restored: there is no CLI to attach.
|
|
267
|
+
- **Clean shutdown** (`/shutdown` or signal): `clearDaemonState()` deletes the file. Next daemon start sees nothing to recover. The device agent host closes first, so no later write brings the file back.
|
|
268
|
+
- **Crash** (SIGKILL, OOM, panic): the file remains. Next daemon start calls `restoreCrashedTunnels()`, which restarts each `mode === "detached"` tunnel using the persisted config and stable `tunnelId` (so log files stay continuous). Foreground tunnels are not restored: there is no CLI to attach. Then, after the auto-start tunnels, `restoreCrashedDeviceAgent()` starts the device agent again when `deviceAgent` is set. Its token and server come from `device.json`. An older daemon reading the file ignores the field.
|
|
260
269
|
|
|
261
270
|
Auto-start tunnels are independent of crash recovery. They come from saved configs marked `autoStart: true` (`getAutoStartConfigs()`), not from the state file.
|
|
262
271
|
|
|
@@ -359,6 +368,15 @@ src/
|
|
|
359
368
|
├── tunnel_manager/
|
|
360
369
|
│ └── TunnelManager.ts Singleton wrapping @pinggy/pinggy SDK. Listener fanout.
|
|
361
370
|
│
|
|
371
|
+
├── devices/ Device agent for `pinggy device` (CLI process, reads the daemon)
|
|
372
|
+
│ ├── deviceAgent.ts Connect loop, frame dispatch, heartbeat timer
|
|
373
|
+
│ ├── deviceIdentity.ts device.json read/write/clear, token masking
|
|
374
|
+
│ ├── envelope.ts Versioned frame wrapper + channel/op constants
|
|
375
|
+
│ ├── device_schema.ts Zod schemas for welcome, error, disconnect payloads
|
|
376
|
+
│ ├── tunnels/tunnelList.ts device/tunnels: daemon poll, whitelist, change detection
|
|
377
|
+
│ ├── tunnels/tunnelActions.ts tunnel/start, stop, restart (slice 10), update (10b) against the daemon
|
|
378
|
+
│ └── login/ `pinggy device login` (slices 12, 13): email sign-in over HTTPS, then the agent
|
|
379
|
+
│
|
|
362
380
|
├── remote_management/ Remote control via Pinggy management WS
|
|
363
381
|
│ ├── remoteManagement.ts Connect/disconnect + state machine
|
|
364
382
|
│ ├── handler.ts TunnelHandler interface + TunnelOperations impl
|
|
@@ -384,8 +402,8 @@ src/
|
|
|
384
402
|
|
|
385
403
|
## 11. Key types
|
|
386
404
|
|
|
387
|
-
- `TunnelOrigin = "app" | "cli" | "remote"` (`src/tunnel_manager/TunnelManager.ts`). Set on every tunnel at creation; flows into log filenames.
|
|
388
|
-
- `ClientOrigin = "app" | "cli" | "remote"` (`src/daemon/ipcClient.ts`). Same values, used in the `X-Pinggy-Origin` header.
|
|
405
|
+
- `TunnelOrigin = "app" | "cli" | "remote" | "device"` (`src/tunnel_manager/TunnelManager.ts`). `device` is the device agent acting for the dashboard's device page (slice 10). Set on every tunnel at creation; flows into log filenames.
|
|
406
|
+
- `ClientOrigin = "app" | "cli" | "remote" | "device"` (`src/daemon/ipcClient.ts`). Same values, used in the `X-Pinggy-Origin` header.
|
|
389
407
|
- `TunnelStateType` (`src/types.ts`): `idle | starting | running | live | closed | exited`.
|
|
390
408
|
- `FinalConfig` (`src/types.ts`): the SDK's `TunnelConfigurationV1` plus `conf?` and `saveconf?`. Used by `buildConfig.ts` and threaded through to the daemon.
|
|
391
409
|
- `TunnelResponseV2` (`src/remote_management/handler.ts`): `{tunnelid, remoteurls, tunnelconfig, status, stats, greetmsg?}`. The canonical wire shape.
|
|
@@ -458,7 +476,310 @@ The installer resolves the binary via `resolveBinary()` in `src/daemon/serviceIn
|
|
|
458
476
|
|
|
459
477
|
A system service gives restart-on-failure and start-at-login. The plain daemon mode does not.
|
|
460
478
|
|
|
461
|
-
## 17.
|
|
479
|
+
## 17. Device agent (`pinggy device`)
|
|
480
|
+
|
|
481
|
+
The one long-lived surface that is not a tunnel. `pinggy device connect` enrols the machine with the Pinggy dashboard and holds 1 WebSocket open so the dashboard can show it as online. In the foreground it runs in the CLI process. With `-b` the daemon runs it (slice 17, Background mode below). Code lives in `src/devices/`, entered from `src/cli/subcommand/handlers/deviceCommand.ts`.
|
|
482
|
+
|
|
483
|
+
**Email sign-in (slice 12).** `pinggy device login` gets the device token without the dashboard: it asks for an email, `POST /backend/api/v1/device-agent/login/start` emails a 6-digit code, and the code prompt races a `poll` every few seconds under 1 `AbortController`. A right code (and the TOTP, with MFA on) answers the token once. Since slice 13 the email also carries an Approve link: the CLI prints the `match_code` its page shows, a poll collects a link's approval, and a typed code answered `pending` stops the prompt so the poll can. The CLI writes `device.json`, prints the device's browser link and its QR code (`createQrCodes`, shared with the TUI): a one-time sign-in link after a typed code, the plain device page after the emailed link. Then it runs the same agent. Code in `src/devices/login/`: `deviceLogin.ts` (the command; every dependency injected, so `src/_tests_/deviceLogin.test.ts` runs it with no dashboard or terminal), `loginApi.ts` (`fetch`, `buildDashboardHttpUrl`, the public client token), `login_schema.ts` (zod), `prompts.ts` (`readline/promises`, 1 interface per question, closed on answer or abort; `askHidden` reads the authenticator code without showing it, since the code still works after it is typed and the open link asks for one). A stdin that is not a TTY stops at once and points at `connect --token`. The token is never printed.
|
|
484
|
+
|
|
485
|
+
**Install scripts (slice 14).** `scripts/install/install.sh` (macOS, Linux, POSIX `sh`) and `scripts/install/install.ps1` (Windows PowerShell 5.1 and 7) install or update the binary, put `pinggy` on PATH with no sudo, then run `pinggy device login` by full path. `install.sh` writes `~/.pinggy/bin/pinggy` and links `~/.local/bin/pinggy` to it, adding 1 marked block to the shell's rc file when `~/.local/bin` is not on PATH. `install.ps1` writes `%LOCALAPPDATA%\Pinggy\bin\pinggy.exe` and adds that directory to the user PATH. Both read 3 things this repo owns: the release asset names, the `Pinggy CLI version: <x.y.z>` line, and the `device login --manage` flag. A change to any of them changes both scripts in the same commit. `.github/workflows/website-cli-download-links-pr.yml` copies both, read at the release tag, to the website's `static/`, so pinggy.io serves `/install.sh` and `/install.ps1`. Lint with `shellcheck -s sh` and `PSScriptAnalyzer`. The step order is in `install-script-plan.md` under the backend's `docs/pinggy-devices/`.
|
|
486
|
+
|
|
487
|
+
**The token command (slice 16).** The dashboard shows `curl -fsSL https://pinggy.io/install.sh | DEVICE_AGENT_TOKEN=<token> sh`, and `$env:DEVICE_AGENT_TOKEN='<token>'; irm https://pinggy.io/install.ps1 | iex` on Windows. With the variable set, both scripts update an older binary without asking, then run `pinggy device connect` by full path, with `--manage` when `PINGGY_MANAGE` is set. The token reaches the CLI in its environment, never on a command line, where `ps` would show it. `install.sh` gives `connect` `/dev/null` as stdin, since it reads no key. `install.ps1` removes the variable in a `finally`, because `iex` runs in the user's own window. `handleDevice()` reads `DEVICE_AGENT_TOKEN` and deletes it from `process.env` before any verb runs, so no shell, daemon or other child inherits it (`takeEnvironmentToken()` in `deviceCommand.ts`). `connect` then picks the token from `--token`, else the variable, else `device.json`, and the server from `--manage`, else `dashboard.pinggy.io` for a token from the variable, else the server in `device.json` (`resolveConnectTarget()`). An empty variable counts as unset.
|
|
488
|
+
|
|
489
|
+
The design lives in the `pinggy_backend` repo under `docs/pinggy-devices/`: `cli.md` is the CLI contract, `api-websocket.md` the wire format, `slices/` the delivery order. This section records what the CLI implements today.
|
|
490
|
+
|
|
491
|
+
**In the foreground it runs in the CLI process.** The agent owns no tunnel. Since slice 09 it reads the daemon: `getDaemonInfo()` for `daemon.json`, then `IPCClient.listTunnels()` for `GET /tunnels`, every 5 s, and `listSavedConfigs()` for the saved configs. Listing never starts a daemon, and it refuses a daemon whose `ipcVersion` differs, as `ensureDaemonRunning()` does. Since slice 10 it also acts on the daemon when the dashboard asks (see Device tunnel actions below); only a start of a config may call `ensureDaemonRunning()`. `TunnelManager` and `TunnelClient` stay off this path. Killing the agent leaves running tunnels alone.
|
|
492
|
+
|
|
493
|
+
**It does not reuse `src/remote_management/`.** Remote management is a dashboard-driven tunnel controller keyed by an API key. A device is the machine itself, keyed by its own token, and the 2 sockets speak different protocols. The duplication is deliberate: the backend `constraints.md` lists the remote-management files as do-not-touch, and that loop keeps its fixed 5000 ms retry with no cap and no watchdog.
|
|
494
|
+
|
|
495
|
+
### Background mode (slice 17)
|
|
496
|
+
|
|
497
|
+
`pinggy device connect -b` and `pinggy device login -b` hand the agent to the daemon, as `pinggy start -b` hands it a tunnel, and exit once the device connects. `-b` is the existing `cliOptions.b`. Whoever hosts the daemon hosts the agent: the CLI's detached child, `pinggy daemon install-service`, or the Pinggy app, whose in-process daemon runs the same `runDaemonChild()`. The app reaches it through `TunnelClient.getDeviceAgentStatus()`, `startDeviceAgent()` and `stopDeviceAgent()`.
|
|
498
|
+
|
|
499
|
+
**The token never goes over IPC.** `device.json` is the hand-off: the CLI writes it, the daemon reads it. The 3 `/device` routes (section 5) read no request field. A target that differs from `device.json` (another token, or another server) is checked first in the CLI with `startDeviceAgent(..., { verifyOnly: true })`: 1 attempt, ended at `welcome`, which writes `device.json` as the foreground agent does. A token the dashboard refuses never reaches the file or the daemon.
|
|
500
|
+
|
|
501
|
+
`connect -b`, in `src/devices/backgroundAgent.ts`:
|
|
502
|
+
|
|
503
|
+
1. `takeEnvironmentToken()` and `resolveConnectTarget()`, as in the foreground.
|
|
504
|
+
2. A target that differs from `device.json`: the verify above. A refusal prints it and exits 1.
|
|
505
|
+
3. `ensureDaemonRunning()`.
|
|
506
|
+
4. `POST /device/start`. An older daemon answers 404, and the CLI says to stop it, or to update the Pinggy app when the app hosts it (`olderDaemonMessage()`).
|
|
507
|
+
5. `GET /device` every 500 ms for up to 15 s. `connected` prints the device, the email, the device link and the log path. `stopped` prints `lastError` and exits 1. Still connecting leaves it to the daemon and exits 0.
|
|
508
|
+
|
|
509
|
+
`login -b` signs in as the foreground does, then runs steps 3 to 5 in place of `runAgent`. `device stop` calls `POST /device/stop`. `device status` adds a `Background` line from `GET /device`. `device remove` stops a running background agent before it deletes `device.json`. A foreground `connect` or `login` refuses while `GET /device` reports a run: 2 agents on 1 credential refuse each other with `already_connected`.
|
|
510
|
+
|
|
511
|
+
**`DeviceAgentHost`** (`src/daemon/deviceAgentHost.ts`) holds at most 1 run, built in `runDaemonChild()` once the IPC port is known. `start(origin)` reads `device.json`: none answers `DEVICE_NOT_ENROLLED`, a run on the same token and server is left alone, and a run on another is stopped before the new one starts. Start and stop run 1 at a time through 1 promise chain. A run that ends on its own (401, revoked, deleted, replaced) keeps why in `lastError`. The host records the run in `daemon-state.json` through `trackDeviceAgentStart()` and `trackDeviceAgentStop()`; `close()` on shutdown records nothing.
|
|
512
|
+
|
|
513
|
+
The daemon's run differs from the foreground one in 3 dependencies, passed through `DeviceAgentOptions`:
|
|
514
|
+
|
|
515
|
+
- **Reporter.** The loop prints nothing itself. It calls a `DeviceAgentReporter` (`src/devices/deviceAgentReporter.ts`). The foreground's `printingReporter` prints the same lines as before. The daemon's logs to `daemon.log` and keeps the latest state for `GET /device`, without the email.
|
|
516
|
+
- **Its own port.** `selfTunnelListSource(port)` and `selfTunnelActionDependencies(port)` reach the daemon that runs the agent, never `daemon.json` and never `ensureDaemonRunning()`. From inside a daemon, that call would spawn a 2nd daemon, or host a 2nd one in the app's process, whenever `daemon.json` goes missing.
|
|
517
|
+
- **`writeIdentityWhileOwned()`.** The daemon's run rewrites `device.json` on `welcome` only while the file holds its own token. `connect -b` with a new token writes the new credential, then asks the daemon to switch; an old run that reconnects in between must not write the old one back.
|
|
518
|
+
|
|
519
|
+
| Event | The background agent |
|
|
520
|
+
| --- | --- |
|
|
521
|
+
| The CLI that ran `connect -b` exits, or its terminal closes | Keeps running. The daemon is detached |
|
|
522
|
+
| Daemon crash | Restored at the next daemon start (section 8) |
|
|
523
|
+
| `pinggy daemon stop`, or the Pinggy app quits | Stops. The socket closes with 1000 and every shell ends |
|
|
524
|
+
| Reboot | Stops, as a `-b` tunnel does |
|
|
525
|
+
| A terminal outcome | Stops, with `lastError`, and is removed from `daemon-state.json` |
|
|
526
|
+
|
|
527
|
+
**Inside the Pinggy app** `node-pty` resolves inside `app.asar`, where `spawn-helper` is not on real disk, and a spawn without its helper kills the process. `resolveNodePtyRoot()` copies the package out of an `.asar` archive the way it does out of a `pkg` snapshot, and `loadNodePty()` offers a terminal only when `hasExecutableSpawnHelper()` holds.
|
|
528
|
+
|
|
529
|
+
`IPC_VERSION` stays 1: the routes are additive, and a bump would make every installed app refuse a newer CLI daemon.
|
|
530
|
+
|
|
531
|
+
### Modules
|
|
532
|
+
|
|
533
|
+
| File | Holds |
|
|
534
|
+
| --- | --- |
|
|
535
|
+
| `src/devices/envelope.ts` | The versioned frame wrapper. `request()`, `event()`, `parseEnvelope()`, channel and op constants |
|
|
536
|
+
| `src/devices/device_schema.ts` | Zod schemas for `welcome`, error, and `disconnect` payloads. Types for `hello`, `heartbeat`, `device/info`, `device/metrics`, and `device/tunnels` |
|
|
537
|
+
| `src/devices/deviceIdentity.ts` | `device.json` read, write, clear, and token masking |
|
|
538
|
+
| `src/devices/deviceAgent.ts` | URL build, connect loop, frame dispatch, heartbeat and metrics timers. `startDeviceAgent()` returns a run with `stop()` and `done`; `runDeviceAgent()` is the foreground entry with the SIGINT handler |
|
|
539
|
+
| `src/devices/deviceAgentReporter.ts` | `DeviceAgentReporter`, the run's events. `printingReporter`: the foreground's terminal output. `printConnected()` |
|
|
540
|
+
| `src/devices/backgroundAgent.ts` | The CLI's half of `-b`: verify a new target, start the daemon's agent, wait for `connected`. `device stop`, the `status` line, the stop before `remove`, the foreground refusal |
|
|
541
|
+
| `src/daemon/deviceAgentHost.ts` | `DeviceAgentHost`: the daemon's 1 run, behind the `/device` routes. `writeIdentityWhileOwned()` |
|
|
542
|
+
| `src/devices/collectors/systemInfo.ts` | `collectSystemInfo()`: the `device/info` payload |
|
|
543
|
+
| `src/devices/collectors/metrics.ts` | `collectMetrics()`: the `device/metrics` payload, with `cpu_percent` from 2 samples |
|
|
544
|
+
| `src/devices/tunnels/tunnelList.ts` | `collectTunnelList()`: the `device/tunnels` payload, built from named fields of `GET /tunnels` and the saved configs. `startTunnelReporting()`: send at once, poll every 5 s, send only on change. `selfTunnelListSource(port)` for the daemon's own run |
|
|
545
|
+
| `src/devices/tunnels/tunnelActions.ts` | `handleTunnelAction()`: `tunnel/start`, `stop` and `restart` against the daemon, with the `device` origin, answered on the request's id and op. `selfTunnelActionDependencies(port)` for the daemon's own run |
|
|
546
|
+
| `src/devices/terminal/terminal_schema.ts` | Terminal ops, zod schemas for `open`, `close`, `ack`, `resize` and the `snapshot` request, `TerminalData`, `TerminalExit`, `TerminalHeld`, `TerminalContext`, `TerminalSnapshotPart`, refusal codes, grid clamp |
|
|
547
|
+
| `src/devices/terminal/terminalHandler.ts` | `TerminalHandler`: 1 per agent run, not per socket. `open` spawns and answers `opened`, output goes up as `data`, `ack` reopens the window, `close` kills, `resize` resizes, a shell exit sends `exit` then `close` up. `suspend`, `held`, `resumeAll` carry shells across a reconnect. While any shell is open, `pollShellContexts` reads the process table every 5 s and sends `context` on change, and `resumeAll` sends every shell's context again. `snapshot` answers a tab that attached with the shell's screen, in parts |
|
|
548
|
+
| `src/devices/terminal/screenMirror.ts` | `ScreenMirror`: 1 shell's screen, a headless xterm fed every bundle as it is sent, serialised on request. `isScreenMirrorSupported()`: both xterm packages load |
|
|
549
|
+
| `src/devices/terminal/flowWindow.ts` | `FlowWindow`: sent minus acked, in raw bytes, against the window from `welcome`. Closed means stop reading the pty |
|
|
550
|
+
| `src/devices/terminal/frameSplitter.ts` | `FrameSplitter`: bundles pty output into `data` frames at the raw cap derived from `max_frame_bytes`, or after `BUNDLE_MILLIS` |
|
|
551
|
+
| `src/devices/terminal/shellContext.ts` | Each shell's `cwd` and foreground program from the process table: `/proc` on Linux, `ps` and `lsof` on macOS, nothing on Windows. `programName()` keeps the first word, basename only, because a process can write its arguments into its own name. `ShellContextTracker` turns polls into `context` events, only on change |
|
|
552
|
+
| `src/devices/terminal/terminalRegistry.ts` | `TerminalRegistry`: terminal id to pty handle, with the per-device ceiling from `welcome` |
|
|
553
|
+
| `src/devices/terminal/ptySession.ts` | Lazy `node-pty` load, `isTerminalSupported()` (false unless `spawn-helper` can be exec'd), `spawnPty()`, the `spawn-helper` execute-bit repair, `signalForeground()` to the tty's foreground process group |
|
|
554
|
+
| `src/devices/terminal/nodePtyRuntime.ts` | `resolveNodePtyRoot()`: where `node-pty` loads from. Unpacks it onto real disk inside a `pkg` binary or an Electron `.asar` archive. `hasExecutableSpawnHelper()` |
|
|
555
|
+
| `src/devices/terminal/shellAllowlist.ts` | `resolveShell()`: exact match against `/etc/shells`, or `powershell.exe` and `cmd.exe` on Windows |
|
|
556
|
+
| `src/cli/subcommand/handlers/deviceCommand.ts` | The `login`, `connect`, `stop`, `status`, `remove` verbs, and `-b`. `refuseDevicesSpelling()` for the old spelling |
|
|
557
|
+
| `src/utils/helpMessages.ts` | `printDeviceHelp()` |
|
|
558
|
+
|
|
559
|
+
`Subcommand.Device` in `src/cli/configStore.ts` puts `device` in `SUBCOMMANDS` and `RESERVED_NAMES`, so no new saved tunnel config can be named `device`. `Subcommand.Devices` keeps the old spelling in both. `pinggy devices` prints `"pinggy devices" is now "pinggy device". Run: pinggy device <verb>` and exits 1. It names the verb only when it is 1 of the 5, else `--help`, and never repeats the other arguments, which can hold a token. Without it, `devices` would fall to legacy tunnel mode, where `--token` is the tunnel token, and a device token would reach the tunnel server.
|
|
560
|
+
|
|
561
|
+
### The envelope
|
|
562
|
+
|
|
563
|
+
Every frame, both directions:
|
|
564
|
+
|
|
565
|
+
```json
|
|
566
|
+
{"v": 1, "kind": "req|res|event", "ch": "system", "op": "hello",
|
|
567
|
+
"id": "uuid on a req, empty on an event", "seq": 0, "ts": 1750000001, "payload": {}}
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
`ch` and `op` discriminate. `parseEnvelope()` returns `null` on anything unparseable instead of throwing, and an unknown `ch` or `op` is logged and ignored.
|
|
571
|
+
|
|
572
|
+
**Never close the socket over an unrecognised frame.** That single rule is what lets the dashboard ship a channel this build has never heard of, and what let the `terminal` channel arrive without a lockstep deploy. Any code path that throws on an unknown `ch` or `op` turns a forward-compatible protocol into one that needs lockstep deploys.
|
|
573
|
+
|
|
574
|
+
### Connect sequence
|
|
575
|
+
|
|
576
|
+
```
|
|
577
|
+
agent dashboard
|
|
578
|
+
│ GET /backend/api/v1/device-agent/ws/connect │
|
|
579
|
+
│ X-Pinggy-Device-Token: <token> │
|
|
580
|
+
├───────────────────────────────────────────────────────▶│ 401 before the upgrade if the token is bad
|
|
581
|
+
│ system/hello {agent_version, os, hostname, │
|
|
582
|
+
│ capabilities: [tunnel, stats, terminal],│
|
|
583
|
+
│ terminals: [shells still held]} │
|
|
584
|
+
├───────────────────────────────────────────────────────▶│
|
|
585
|
+
│ system/welcome {device_agent_id, accepted_proto, │
|
|
586
|
+
│ heartbeat_interval_seconds, ...} │
|
|
587
|
+
│◀───────────────────────────────────────────────────────┤
|
|
588
|
+
│ device.json written 0600 │
|
|
589
|
+
│ device/info {hostname, os, os_version, arch, ...} │
|
|
590
|
+
├───────────────────────────────────────────────────────▶│ once
|
|
591
|
+
│ device/metrics {cpu_percent, load_avg_*, memory_*} │
|
|
592
|
+
├───────────────────────────────────────────────────────▶│ at once, then every stats_interval_seconds
|
|
593
|
+
│ system/heartbeat {uptime_seconds} │
|
|
594
|
+
├───────────────────────────────────────────────────────▶│ every heartbeat_interval_seconds
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
The credential rides in `X-Pinggy-Device-Token`, not `Authorization`. The dashboard routes `Authorization: Bearer` by token length in `SecurityConfig`, and that path is off limits to this feature.
|
|
598
|
+
|
|
599
|
+
`welcome` answers `hello` whether or not the handshake succeeded. A refusal comes back on `welcome` too, with `{"error": {"code", "message"}}` as the payload, so the agent has exactly 1 branch to write.
|
|
600
|
+
|
|
601
|
+
On each `welcome` the agent prints "Connected as device <id>", then the owner's `email` and the device's `device_url` when `welcome` carries them (`printConnected()`). An older dashboard sends neither, and the agent prints only the id.
|
|
602
|
+
|
|
603
|
+
**Every cadence comes from `welcome`.** `heartbeat_interval_seconds` and `stats_interval_seconds` are server-assigned with no compiled-in fallback, so an operator retunes them without shipping a new agent. Add new ceilings there rather than inventing a second negotiation.
|
|
604
|
+
|
|
605
|
+
### Device info and metrics
|
|
606
|
+
|
|
607
|
+
After each `welcome` the agent sends `device/info` once, then starts `startMetricsReporting()`: 1 `device/metrics` frame at once, then 1 every `stats_interval_seconds`. Both timers stop when the socket settles, and a reading still sampling at that moment is dropped rather than sent late.
|
|
608
|
+
|
|
609
|
+
The collectors use Node's `os` module and nothing else. `systeminformation` and `node-os-utils` are out: a native addon may not load in the `node20` pkg binary. `node-pty` is the 1 exception, because nothing built in allocates a pty.
|
|
610
|
+
|
|
611
|
+
- `arch` is `os.machine()` (`x86_64`, `arm64`), not `os.arch()` (`x64`), so it matches `uname`.
|
|
612
|
+
|
|
613
|
+
### Device tunnels
|
|
614
|
+
|
|
615
|
+
After each `welcome` the agent also starts `startTunnelReporting()`: 1 `device/tunnels` frame at once, then a poll every 5 s that sends only when the list changed. Change detection compares the serialised list without `collected_at`; stats are not in the list, so traffic alone sends nothing. The poll stops when the socket settles, and a list still being read is dropped.
|
|
616
|
+
|
|
617
|
+
The list is a whitelist. Each tunnel row is built field by field: id, config id, name, state, error message (control characters dropped, 256 characters), public URLs, forwarding (`type` and `local_address`), mode, and 2 times. The token, basic auth, bearer tokens, header modifications, IP whitelist, server address, debugger address, `optional`, and stats stay on the machine. Saved configs send `config_id`, `name`, and `running` only. At most 50 tunnels and 100 saved configs, newest first, with `truncated`.
|
|
618
|
+
|
|
619
|
+
No daemon, or a dead pid in `daemon.json`, sends `daemon_running: false` with the saved configs still listed. A live daemon on another IPC version sends `daemon_unavailable_reason: "ipc_version_mismatch"` and is not asked; a live pid whose port does not answer sends `daemon_unreachable`.
|
|
620
|
+
|
|
621
|
+
### Device tunnel actions
|
|
622
|
+
|
|
623
|
+
`ch: "tunnel"` frames go to `handleTunnelAction()` (slice 10). The dashboard sends 4 requests, and the agent answers each on its `id` and op with `{tunnel_id, state}`, `{config_id}` for a rewritten saved config, or an error. The agent advertises `tunnel_control` in `hello` for start, stop and restart, `tunnel_update` for update (slice 10b), and `saved_config_update` for an update of a saved config; a dashboard refuses an op to an agent without its capability.
|
|
624
|
+
|
|
625
|
+
| Request | Daemon route | Starts a daemon |
|
|
626
|
+
| --- | --- | --- |
|
|
627
|
+
| `stop {tunnel_id}` | `POST /tunnels/stop` | no |
|
|
628
|
+
| `restart {tunnel_id}` | `POST /tunnels/restart`, `noWait` | no |
|
|
629
|
+
| `start {tunnel_id}` | `POST /tunnels/restart`, `noWait`. The daemon keeps a stopped tunnel, and restart starts it under the same id | no |
|
|
630
|
+
| `start {source: "device", config_id}` | `POST /tunnels/start-config`, `noWait`, detached, with the saved config whose `configId` is exactly `config_id` | yes |
|
|
631
|
+
| `start {source: "dashboard", config}` | `POST /tunnels/start-config`, `noWait`, detached, after `TunnelConfigV1Schema` and with null fields dropped | yes |
|
|
632
|
+
| `update {source: "dashboard", config}` | `GET /tunnels` to find the tunnel whose `configId` is exactly the config's, then `POST /tunnels/update-config-v2`, `noWait`. None found is `tunnel_not_found`, and the daemon is not asked | no |
|
|
633
|
+
| `update {source: "device", config_id, config}` | None. The saved config whose `configId` is exactly `config_id` is rewritten through `upsertConfig()`, after `TunnelConfigV1Schema`, keeping its id, `autoStart` and `uiMetadata`. A new name must pass `validateNameStrict()` (`invalid_payload`) and be free (`saved_config_name_taken`). A tunnel already running the config keeps what it runs | no |
|
|
634
|
+
|
|
635
|
+
A restart, and an update of a running tunnel, keep the tunnel in `GET /tunnels` as `starting` until the new instance replaces the old one, so the `pollNow()` after the answer finds it. A failed rebuild leaves it `exited`, with the reason in `errormsg`. A 2nd restart or update during the rebuild answers `TUNNEL_WITH_ID_OR_CONFIG_ID_ALREADY_RUNNING`, which the agent turns into `tunnel_action_failed`.
|
|
636
|
+
|
|
637
|
+
Every call goes through an `IPCClient` with origin `device`. With no daemon, a stop, restart or dashboard update answers `tunnel_not_found`. The daemon's `TUNNEL_WITH_ID_OR_CONFIG_ID_NOT_FOUND` becomes `tunnel_not_found`; any other refusal, and any throw, becomes `tunnel_action_failed` with the message cleaned and cut to 256. The dashboard config holds a token: no log line carries a config or a payload, only the op, the ids and the outcome code. After each action the agent calls `pollNow()` on the tunnel reporting, so the changed list reaches the page at once instead of on the next 5 s poll; a `pollNow()` during a read in flight reads again when it ends.
|
|
638
|
+
- `cpu_percent` samples `os.cpus()` twice, 200 ms apart, and differences the idle and total ticks. 1 reading is cumulative since boot and gives a flat, wrong number.
|
|
639
|
+
- `load_avg_1m`, `load_avg_5m`, `load_avg_15m` are `[0, 0, 0]` on Windows and are sent anyway, so the payload shape never varies by platform.
|
|
640
|
+
- `memory_used_bytes` is `totalmem - freemem`. On macOS that counts file cache as used.
|
|
641
|
+
|
|
642
|
+
The dashboard answers an unreadable payload with `invalid_payload` on the `device` channel. The agent ignores every non-`system` frame, so that answer is logged at debug and changes nothing.
|
|
643
|
+
|
|
644
|
+
### Terminal (slices T1, T1b, T2 and T3: open, close, shells that outlive the socket, output, input)
|
|
645
|
+
|
|
646
|
+
`ch: "terminal"` frames go to the run's `TerminalHandler`, never to `handleFrame()`. Output flows up as `data`, and keystrokes flow down as `data`.
|
|
647
|
+
|
|
648
|
+
```
|
|
649
|
+
dashboard agent
|
|
650
|
+
│ terminal/open req {terminal_id, cols, rows, shell, cwd}│
|
|
651
|
+
├───────────────────────────────────────────────────────▶│ allowlist, spawn
|
|
652
|
+
│ terminal/opened res {terminal_id, pid, shell, cols, rows}
|
|
653
|
+
│◀───────────────────────────────────────────────────────┤ same envelope id as the open
|
|
654
|
+
│ terminal/data event {terminal_id, data: base64} │
|
|
655
|
+
│◀───────────────────────────────────────────────────────┤ pty output, bundled
|
|
656
|
+
│ terminal/ack event {terminal_id, ack_seq, ack_bytes} │
|
|
657
|
+
├───────────────────────────────────────────────────────▶│ cumulative raw bytes drawn
|
|
658
|
+
│ terminal/close event {terminal_id, reason} │
|
|
659
|
+
├───────────────────────────────────────────────────────▶│ kill, send nothing back
|
|
660
|
+
│ terminal/data event {terminal_id, data: base64} │
|
|
661
|
+
├───────────────────────────────────────────────────────▶│ keystrokes, written to the pty
|
|
662
|
+
│ terminal/signal event {terminal_id, signal} │
|
|
663
|
+
├───────────────────────────────────────────────────────▶│ INT, TERM, QUIT or HUP only
|
|
664
|
+
│ terminal/resize event {terminal_id, cols, rows} │
|
|
665
|
+
├───────────────────────────────────────────────────────▶│ the tabs watching it changed size
|
|
666
|
+
│ terminal/exit event {terminal_id, exit_code, signal} │
|
|
667
|
+
│ terminal/close event {terminal_id, user_closed} │
|
|
668
|
+
│◀───────────────────────────────────────────────────────┤ only when the shell exits on its own
|
|
669
|
+
│ terminal/snapshot event {terminal_id, snapshot_id} │
|
|
670
|
+
├───────────────────────────────────────────────────────▶│ a tab attached and asks for its screen (T6)
|
|
671
|
+
│ terminal/snapshot event {part, last, after_seq, data} │
|
|
672
|
+
│◀───────────────────────────────────────────────────────┤ the screen, to that tab only
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
- **`terminal` is advertised only when `node-pty` loads.** `buildCapabilities(isTerminalSupported())`. The capability is what un-greys the Terminal button, so an agent must never advertise a shell it cannot spawn.
|
|
676
|
+
- **The dashboard validates neither shell nor cwd.** `resolveShell()` is the only check. A requested shell must equal an `/etc/shells` line exactly and exist; no request picks `$SHELL` when it is allowed. A missing cwd falls back to home rather than refusing.
|
|
677
|
+
- **A refusal answers on `opened`** with `{terminal_id, error: {code, message}}`: `invalid_payload`, `terminal_disabled`, `terminal_limit_reached`, `shell_not_allowed`, or `spawn_failed`.
|
|
678
|
+
- **A shell outlives the socket that opened it** (slice T1b). `runDeviceAgent()` builds 1 `TerminalHandler` for the whole run. When `connectOnce()` settles, `suspend()` pauses every pty (stops reading its master, so the shell blocks once the kernel buffer fills) and kills none. The next `hello` lists them in `terminals`. The dashboard answers `close` with `device_gone` for any it forgot, possibly before `welcome`, and `welcome` calls `resumeAll()`.
|
|
679
|
+
- **Only the agent stopping kills shells.** When the loop ends (interrupted, revoked, refused), `closeAll()` kills them. Killing the agent process reaps them too, through `SIGHUP` when the pty master closes.
|
|
680
|
+
- **A shell that exits while the socket is down sends nothing.** Its `close` has no socket to go to, and the next `hello` simply omits it. The dashboard records that as `device_gone`.
|
|
681
|
+
- **Output is read as raw bytes and sent as base64.** `spawnPty()` passes `encoding: null`, so a read that ends mid-character is not decoded into a replacement character. `FrameSplitter` sends a bundle at the raw cap from `rawBundleBytes(max_frame_bytes)` or after `BUNDLE_MILLIS`, whichever comes first. A frame above `max_frame_bytes` would close the socket, and every other terminal on it.
|
|
682
|
+
- **A full window stops the reads, not the sends.** `FlowWindow` counts raw bytes sent minus the browser's cumulative `ack_bytes`, clamped to what was sent. When it reaches the window, the handler pauses the pty and the shell blocks in the kernel. The next `ack` that reopens it resumes reads. Nothing is buffered on the agent.
|
|
683
|
+
- **Ctrl-C is input, not a signal.** It arrives as `data` byte `0x03`, and the pty's line discipline turns it into SIGINT for the foreground process group.
|
|
684
|
+
- **`signal` goes to the foreground process group**, never the shell's pid. `signalForeground()` reads the tty's `tpgid` with `ps` and calls `process.kill(-tpgid)`, falling back to the shell's own group. `node-pty`'s `kill(signal)` signals the pid only, and bash ignores SIGINT while `sleep` holds the terminal. The allowlist is `TERMINAL_SIGNALS`, enforced by the zod schema, so `KILL` and anything else never reach `kill`. Ignored on Windows.
|
|
685
|
+
- **`resize` touches the pty only on an actual change.** Every call is a `TIOCSWINSZ` and a SIGWINCH, and a full-screen program redraws on each.
|
|
686
|
+
- **2 things pause a pty, and neither lifts the other's.** A suspended handler (socket down) and a full window. `resumeAll()` skips a shell whose window is still full, and an `ack` does not resume a shell while the handler is suspended.
|
|
687
|
+
- **The window has no default.** `welcome.terminal_window_bytes` is the only place the number lives. Without it the handler answers every `open` with `terminal_disabled`.
|
|
688
|
+
- **`seq` counts per terminal from 1**, on every `data` frame, for the life of the shell rather than the socket. The dashboard closes a terminal with `sequence_gap` on a hole.
|
|
689
|
+
- **A bundle cut while the socket is down is held**, not sent into no socket. It takes no `seq` and does not count against the window until `resumeAll()` sends it first. It is at most what was already read when `suspend()` paused the pty.
|
|
690
|
+
- **The window still assumes 1 viewer.** T1b's multi-viewer rules for T2 (slowest viewer's ack, 10 s `too_slow` detach) are not built. Its replay for a late attach is T6's screen, below, not a buffer of bytes.
|
|
691
|
+
- **Every shell keeps its screen** (slice T6). `ScreenMirror` is a headless xterm (`@xterm/headless`, 1000 lines of scrollback) fed each bundle in `sendData`, in the same tick as its `seq`, never when the pty is read: a bundle still being cut would otherwise be in the screen and drawn twice. `snapshot` notes `after_seq` and `after_bytes`, then serialises inside the callback of an empty write queued on the mirror, which xterm runs before it parses anything fed later. The text goes out in parts of the raw bundle size. Parts take no `seq` and never count against the window. Over 1 MB it is serialised again with 200 lines, then with the screen alone, and past that it is not sent. The serialize addon needs `allowProposedApi`.
|
|
692
|
+
- **`terminal_snapshot` is advertised only beside `terminal`**, and only when both xterm packages load. The mirror is memory only, disposed with its shell, never logged and never written to disk.
|
|
693
|
+
- **No log line carries a payload.** Ids, pids, and codes only, and an error's class name rather than its message. Input is what a person typed, so it is never logged at any level.
|
|
694
|
+
- `welcome.terminal_enabled` and `welcome.max_terminals_per_device` are optional, so an older dashboard leaves the defaults (enabled, 3).
|
|
695
|
+
|
|
696
|
+
`node-pty` 1.1.0 publishes its macOS `spawn-helper` without the execute bit, and every spawn then fails with `posix_spawnp failed`. `ensureSpawnHelperExecutable()` restores it once, at load.
|
|
697
|
+
|
|
698
|
+
**A packaged binary loads `node-pty` from real disk, not from the snapshot.** `node-pty` does not exec the shell directly. It execs `spawn-helper`, at a path it derives from wherever its own module sits. Inside a `pkg` binary that path is `/snapshot/...`, which only exists in the `fs` module pkg patches, so the kernel cannot exec it and `posix_spawn` fails with `ENOENT`. The throw is fatal inside the addon, so no caller gets to answer for it.
|
|
699
|
+
|
|
700
|
+
`resolveNodePtyRoot()` copies the package out of the snapshot on first use and returns that path, so every path `node-pty` derives afterwards is a real one. It copies `package.json`, `lib/`, and this platform's native directory only: 41 files and 404 KB, against 62 MB for the whole package, most of which is other architectures' prebuilds. The copy lands in `~/.config/pinggy/runtime/node-pty-<version>-<platform>-<arch>`, staged under a sibling name and renamed into place, so 2 agents starting at once cannot load a half-written copy. An unpackaged run resolves through `require` as normal and copies nothing.
|
|
701
|
+
|
|
702
|
+
Not `pkg`'s own cache. `pkg` already unpacks the addon to `~/.cache/pkg/<hash>/` at mode 644, but the hash is undocumented and `node-pty` never looks there.
|
|
703
|
+
|
|
704
|
+
Measured on `macos-arm64` and `macos-x64`. `linux-x64`, `linux-arm64` and `win-x64` are unproven: that is T0 in the backend docs.
|
|
705
|
+
|
|
706
|
+
### Outcomes
|
|
707
|
+
|
|
708
|
+
`connectOnce()` resolves `"retry"` or `"terminal"`. The run's loop retries on the first and ends on the second. A terminal outcome ends the run with a `DeviceAgentError`: `unauthorized`, `handshake_refused`, the disconnect reason (`revoked`, `deleted`, `replaced`), or `disconnected` for any other. A verify run (`verifyOnly`) makes 1 attempt and ends with `not_connected` when it gets no `welcome`.
|
|
709
|
+
|
|
710
|
+
| Signal | Outcome |
|
|
711
|
+
| --- | --- |
|
|
712
|
+
| HTTP 401 on the upgrade | terminal |
|
|
713
|
+
| Close code 4001 | terminal |
|
|
714
|
+
| `welcome` with an error other than `already_connected` | terminal |
|
|
715
|
+
| `system/disconnect` with any reason but `shutdown` | terminal |
|
|
716
|
+
| `welcome` with `already_connected` | retry. A live node elsewhere holds the device. The dashboard closes with 4002, and the close handler schedules the retry |
|
|
717
|
+
| `system/disconnect` with `shutdown` | retry. The node is draining, the credential is fine |
|
|
718
|
+
| Any other close code, socket error, or unreadable `welcome` | retry |
|
|
719
|
+
|
|
720
|
+
Retry sleeps an exponential backoff with full jitter: 1 s doubling to a 60 s cap, drawn as `random(0, ceiling)`, reset to 1 s only after a connection holds 60 s. The schedule lives in `src/devices/reconnect.ts` as pure functions over an injected clock, so it is testable without a socket; `deviceAgent.ts` owns the timers.
|
|
721
|
+
|
|
722
|
+
2 timers catch a socket that died without saying so, since a dropped link sends nothing and leaves `readyState` at `OPEN`. A handshake timer gives the dashboard 30 s to answer `hello` with `welcome`. After `welcome` a pong watchdog pings every heartbeat interval and drops the socket after 2 unanswered. Both call `terminate()` rather than `close()`, because a polite close waits for a close frame from the peer that has already stopped answering. `ws` auto-answers inbound pings on its own, so the agent sends its own and does not handle theirs.
|
|
723
|
+
|
|
724
|
+
`stop()` ends a run at once: it cancels a backoff sleep, closes the live socket with 1000, drops it with `terminate()` after 3 s without the dashboard's close frame, and resolves when the loop has ended and every shell is closed. In the foreground, `runDeviceAgent()` calls it from a `process.once("SIGINT")` handler, and a second Ctrl+C hits Node's default handler and exits at once. `startDeviceAgent()` installs no signal handler: the daemon and the Pinggy app own their signals.
|
|
725
|
+
|
|
726
|
+
### `device.json`
|
|
727
|
+
|
|
728
|
+
```json
|
|
729
|
+
{"device_agent_id": "0a7d3f6c-2b41-4e8a-9c5d-1e2f3a4b5c6d", "token": "...",
|
|
730
|
+
"server": "dashboard.pinggy.io", "enrolled_at": "2026-09-05T09:22:10Z"}
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
`getDeviceConfigPath()` puts it beside `tunnels/`, not inside it: a device is not a tunnel config. `device_agent_id` is minted by the dashboard and read from `welcome`; the agent never invents one. A token from `--token` or `DEVICE_AGENT_TOKEN` always beats the stored value. When it differs from the stored token, `identityForRun()` starts a new identity, with no old `device_agent_id` and no old `enrolled_at`, and the run reports "This machine was device <old id>. Connecting it with the token from the command." The old device stays in the dashboard, offline.
|
|
734
|
+
|
|
735
|
+
**Written 0600, then chmodded again on every rewrite**, because `writeFileSync` applies `mode` only when it creates the file. This diverges from `configStore.ts`, which writes tunnel tokens at 0644 under a normal umask. Do not copy that pattern into anything new.
|
|
736
|
+
|
|
737
|
+
`pinggy device remove` deletes this file and nothing else, after it stops a background agent. Revoking the device server-side is a dashboard action, because a machine that has lost its credential cannot authenticate the delete.
|
|
738
|
+
|
|
739
|
+
### Composition with other subcommands
|
|
740
|
+
|
|
741
|
+
`isSubcommand()` reads `rawArgs[0]` and nothing else. The first token picks the mode for the whole invocation, and there is no chaining: a second subcommand word is an ordinary argument to the first handler. `handleDevice` takes `args[0]` as its verb and passes the tail to `parseCliArgs(cliOptions, ...)`, which allows positionals, so leftover words are parsed and dropped.
|
|
742
|
+
|
|
743
|
+
| Command | Behaviour | Where it is decided |
|
|
744
|
+
| --- | --- | --- |
|
|
745
|
+
| `device connect start my-tunnel` | Runs the agent. `start` and `my-tunnel` land in `positionals` and are ignored | `deviceCommand.ts`, `parseCliArgs` with `allowPositionals: true` |
|
|
746
|
+
| `device start`, `device daemon stop` | `Unknown command "<verb>"`, `process.exit(1)` | The `default` arm of the verb switch |
|
|
747
|
+
| `device connect -l 3000 -b --all --remote-management KEY` | Runs the agent in the daemon, for `-b`, and ignores the other 3. Only `values.token`, `values.manage` and `values.b` are read. No tunnel and no remote management | `handleDevice` reads 3 fields off `values` |
|
|
748
|
+
| `device connect`, `device login` while the daemon runs an agent | Refused, exit 1: "This machine already runs as device <id> in the background. Stop it first: pinggy device stop" | `foregroundRefusal()` in `backgroundAgent.ts` |
|
|
749
|
+
| `device stop` with no background agent | "No device agent is running in the background.", exit 0. Never starts a daemon | `stopAgentInBackground()` |
|
|
750
|
+
| `device connect --typo` | `Fatal Error: Unknown option '--typo'`, exit 1, before dispatch | `main()` parses the full argv with `parseArgs` in strict mode, then calls `isSubcommand` |
|
|
751
|
+
| `device connect --help` | Ignores `--help` and connects. Only `device` and `device --help` reach `printDeviceHelp()` | The verb switch tests `--help` in its `default` arm only |
|
|
752
|
+
| `device --version` | Unknown verb error. `values.version` is handled after the subcommand branch returns | `main()` order |
|
|
753
|
+
| `start device`, `stop device`, `attach device` | `device` reads as a config or tunnel name and never matches, because `RESERVED_NAMES` blocks the name at save time. `stop` and `attach` still call `ensureDaemon()` first and spawn a daemon | `configStore.ts`, then each handler |
|
|
754
|
+
| `config save device ...` | Rejected by `validateNameStrict` | `configStore.ts` |
|
|
755
|
+
| `-l 3000 device` | Legacy tunnel mode. `device` fails `domainRegex` in `isValidServerAddress`, so it stays a positional and reaches `parseExtendedOptions`, which warns `Unknown extended option "device"`. The tunnel starts normally | `buildConfig.ts`, `extendedOptions.ts` |
|
|
756
|
+
| `--remote-management KEY device connect` | Legacy mode again, because `rawArgs[0]` is a flag. Remote management starts, `device` and `connect` each draw an unknown-extended-option warning, and no agent runs | `main()`, `extendedOptions.ts` |
|
|
757
|
+
| `device connect --_daemon-child` | Runs the daemon child. The internal flag is checked before `isSubcommand` | `main()` order |
|
|
758
|
+
| `devices`, with any verb or flag | The old spelling. Prints the new one, exit 1. Never reaches tunnel mode, so `--token` is never read as the tunnel token | `refuseDevicesSpelling()` in `deviceCommand.ts` |
|
|
759
|
+
|
|
760
|
+
2 of these are rough edges rather than decisions: `--help` after a verb is ignored, and extra positionals after a verb are dropped in silence. Both are 1-line fixes in `handleDevice` if the silence proves confusing.
|
|
761
|
+
|
|
762
|
+
### The agent and the daemon
|
|
763
|
+
|
|
764
|
+
In the foreground the agent shares no state with the daemon or with tunnels, which is what makes the composition table boring.
|
|
765
|
+
|
|
766
|
+
- `pinggy device connect` never calls `ensureDaemon()`, so it neither finds nor spawns one. A device agent on a machine with no daemon writes no `daemon.json`.
|
|
767
|
+
- `pinggy daemon stop` stops tunnels and the daemon. A foreground agent is a separate process and keeps going.
|
|
768
|
+
- Ctrl+C on the agent touches no tunnel. There is no `SessionTracker` entry, no grace timer, and no origin tag.
|
|
769
|
+
- The agent logs through `CLIPrinter` and the CLI logger, not the daemon log. Nothing about it reaches `daemon.log` or the per-tunnel logs.
|
|
770
|
+
|
|
771
|
+
With `-b` the daemon owns the agent: `ensureDaemonRunning()` may start a daemon, `pinggy daemon stop` stops the agent too and says so, the agent logs to `daemon.log`, and `daemon-state.json` records it. It still has no `SessionTracker` entry and no origin tag of its own.
|
|
772
|
+
|
|
773
|
+
1 identity per config dir, last write wins: a run keeps what `device.json` holds while the token matches, sets the server, and rewrites the file on every `welcome`. So `device remove` during a live foreground session deletes a file the agent restores on its next reconnect, and 2 agents sharing 1 credential lose the race by design: the dashboard refuses the second with `already_connected`, and it retries on the backoff until the first socket drops. The daemon's run rewrites the file only while it holds its own token (`writeIdentityWhileOwned()`).
|
|
774
|
+
|
|
775
|
+
### Not built yet
|
|
776
|
+
|
|
777
|
+
In this repo: terminal input, output, flow control, resize, and signals (T2, T3). A `pkg` build that
|
|
778
|
+
carries `node-pty` (T0). Slice 04's backoff and pong watchdog, slice 05's `device/info` and
|
|
779
|
+
`device/metrics` collectors, and T1's terminal open and close have all landed. Everything else
|
|
780
|
+
outstanding is dashboard or frontend work.
|
|
781
|
+
|
|
782
|
+
## 18. Reference for AI agents
|
|
462
783
|
|
|
463
784
|
When making changes, these are the load-bearing files and the contracts that matter.
|
|
464
785
|
|
|
@@ -503,8 +824,11 @@ No em-dashes. Short phrases. Present tense. See `CLAUDE.md` § "English Style" b
|
|
|
503
824
|
- `daemon.json` writes are atomic (write tmp, rename). Direct partial reads should not happen, but `getDaemonInfo()` still tolerates malformed JSON by returning `null` and triggering a respawn.
|
|
504
825
|
- The IPC server only listens on `127.0.0.1`. Do not change to `0.0.0.0` without adding authentication. The current design assumes loopback isolation.
|
|
505
826
|
- Every tunnel reconnects without limit. `_createTunnelWithProcessedConfig()` in `TunnelManager.ts` sets `maxReconnectAttempts` to 0 and ignores the value the caller sends. Create, restart and update all pass through it, so the rule covers the CLI, the app, autostart, crash recovery and remote management. libpinggy counts reconnects over the whole tunnel lifetime and does not reset the count after a successful reconnect. Any limit, including the libpinggy default of 20 for an omitted value, stops a long-running tunnel permanently.
|
|
827
|
+
- Do not route the foreground `pinggy device` through the daemon. With `-b` the daemon runs the agent, and no IPC route takes or returns a token or a server: the daemon reads both from `device.json`. Any local user can reach the IPC port.
|
|
828
|
+
- Do not generalise `src/remote_management/` to serve the device agent. Those files are on the backend `constraints.md` do-not-touch list.
|
|
829
|
+
- `device.json` holds a live credential. Any code that writes it goes through `writeDeviceIdentity()`, which keeps the 0600 mode on rewrites.
|
|
506
830
|
|
|
507
|
-
##
|
|
831
|
+
## 19. Quick diagrams
|
|
508
832
|
|
|
509
833
|
### Start sequence (foreground, daemon already running)
|
|
510
834
|
|
package/CLAUDE.md
CHANGED
|
@@ -28,7 +28,7 @@ node test/e2e/run.cjs out/pinggy-<platform>
|
|
|
28
28
|
The CLI binary has 3 entry modes, dispatched in `src/main.ts`:
|
|
29
29
|
|
|
30
30
|
1. **Daemon child** (`--_daemon-child` flag). Calls `runDaemonChild()` in `src/daemon/daemonChild.ts`. The CLI re-execs itself with this flag when it needs to spawn a daemon; users never invoke it directly.
|
|
31
|
-
2. **Subcommand** (`config`, `start`, `stop`, `ps`, `attach`, `daemon`, `d`). Routes to `handleSubcommand()` in `src/cli/subcommands.ts`.
|
|
31
|
+
2. **Subcommand** (`config`, `start`, `stop`, `ps`, `attach`, `daemon`, `d`, `device`, `devices`). Routes to `handleSubcommand()` in `src/cli/subcommands.ts`.
|
|
32
32
|
3. **Legacy single-tunnel** (no subcommand). Routes to `buildAndStartTunnel()` for flags like `-l 3000` or `-R0:localhost:3000`.
|
|
33
33
|
|
|
34
34
|
The daemon owns the `TunnelManager` singleton, the `@pinggy/pinggy` SDK, the web debugger server, and any `--serve` file servers. The CLI owns argument parsing, the TUI, the remote-management WebSocket client, and the IPC client to the daemon.
|
|
@@ -49,9 +49,10 @@ CLI code calls `TunnelClient` (`src/daemon/tunnelClient.ts`), the public facade
|
|
|
49
49
|
Single daemon per user. State lives under `~/.config/pinggy/` on Linux/macOS or `%APPDATA%/pinggy/` on Windows (helper: `src/utils/configDir.ts`):
|
|
50
50
|
|
|
51
51
|
- `daemon.json`: `{pid, port, startedAt, host, ipcVersion}`. `ipcVersion` is `IPC_VERSION` from `src/daemon/ipc/ipcRoutes.ts`. `ensureDaemonRunning()` refuses a live daemon whose version differs and tells the user to run `pinggy daemon stop`. Bump `IPC_VERSION` on any incompatible IPC or WebSocket change.
|
|
52
|
-
- `daemon-state.json`: detached tunnel configs for crash recovery (`src/daemon/stateStore.ts`). Deleted on clean shutdown; replayed on next start.
|
|
52
|
+
- `daemon-state.json`: detached tunnel configs, and the background device agent (`deviceAgent`), for crash recovery (`src/daemon/stateStore.ts`). Deleted on clean shutdown; replayed on next start.
|
|
53
53
|
- `daemon.log`: SDK + daemon logs. CLI logs stay separate.
|
|
54
54
|
- `tunnels/<name>_<configId>.json`: saved tunnel configs from `pinggy config save`.
|
|
55
|
+
- `device.json`: device agent id and token from `pinggy device connect` (`src/devices/deviceIdentity.ts`). Written 0600, unlike the tunnel configs beside it.
|
|
55
56
|
|
|
56
57
|
### Foreground vs detached tunnels
|
|
57
58
|
|
|
@@ -62,6 +63,22 @@ Single daemon per user. State lives under `~/.config/pinggy/` on Linux/macOS or
|
|
|
62
63
|
|
|
63
64
|
`pinggy attach <name|id>` reopens a foreground subscription and renders the TUI.
|
|
64
65
|
|
|
66
|
+
### Device agent
|
|
67
|
+
|
|
68
|
+
`pinggy device` enrols this machine with the dashboard and holds 1 WebSocket open to it. Code lives in `src/devices/`, entered from `src/cli/subcommand/handlers/deviceCommand.ts`.
|
|
69
|
+
|
|
70
|
+
`pinggy devices`, the old spelling, stays a subcommand that prints the new one and exits 1 (`refuseDevicesSpelling()`). Do not remove `Subcommand.Devices`: without it `devices` falls to legacy tunnel mode, where `--token` is the tunnel token, so `pinggy devices connect --token pga_...` would send the device token to the tunnel server.
|
|
71
|
+
|
|
72
|
+
It runs in the CLI process and owns no tunnel. Since slice 09 it reads the daemon's `GET /tunnels` through `IPCClient` and the saved configs, to send `device/tunnels` (`src/devices/tunnels/tunnelList.ts`), and listing never starts a daemon. Since slice 10 it stops, restarts and starts tunnels when the dashboard asks (`src/devices/tunnels/tunnelActions.ts`), through `IPCClient` with the `device` origin; only a start of a config may call `ensureDaemonRunning()`. Since slice 10b it also applies an updated dashboard config to the tunnel running it, through the daemon's `update-config-v2`, and it rewrites a saved config's file when the dashboard edits it (`upsertConfig()`, no daemon). Since slice 12 `pinggy device login` signs the machine in by email over plain HTTPS (`src/devices/login/`), writes `device.json`, then runs the same agent; since slice 13 the sign-in can also be approved from the email's link, and the poll collects it. No `TunnelManager`. It also does not reuse `src/remote_management/`, which is a tunnel controller keyed by an API key rather than the machine keyed by a device token. Frames use a versioned envelope (`src/devices/envelope.ts`) validated with zod (`src/devices/device_schema.ts`); an unknown `ch` or `op` is ignored, never fatal.
|
|
73
|
+
|
|
74
|
+
Since slice 17, `pinggy device connect -b` and `login -b` run the agent in the daemon, as `pinggy start -b` runs a tunnel there, so it outlives the terminal; whoever hosts the daemon, the CLI's child or the Pinggy app, hosts the agent (`src/daemon/deviceAgentHost.ts`, `src/devices/backgroundAgent.ts`). `GET /device`, `POST /device/start` and `POST /device/stop` reach it, and `TunnelClient` exposes them for the app. No IPC route takes or returns a token or a server: the daemon reads both from `device.json`, and a new token is checked in the CLI with 1 handshake before the daemon sees it. `pinggy device stop` stops it. A foreground `connect` or `login` refuses while it runs. The loop prints nothing itself: it calls a `DeviceAgentReporter`, and the foreground's prints the same lines as before.
|
|
75
|
+
|
|
76
|
+
1 invocation runs 1 subcommand: `isSubcommand()` reads `rawArgs[0]` only, so `pinggy device connect start my-tunnel` runs the agent and drops the rest, and tunnel flags on a `device` line parse and are ignored, except `-b`.
|
|
77
|
+
|
|
78
|
+
Since slice 14, `scripts/install/install.sh` and `install.ps1` install or update the binary, put it on PATH, and run `pinggy device login`. pinggy.io serves copies that `website-cli-download-links-pr.yml` writes at each release tag. Renaming a release asset, changing the `Pinggy CLI version:` line, or changing `device login`'s `--manage` flag means changing both scripts in the same commit. Since slice 16, `DEVICE_AGENT_TOKEN` on either script updates without asking and runs `pinggy device connect` instead, with the token in the environment, never on a command line. `handleDevice()` deletes the variable from `process.env` before any verb runs, so no shell or daemon the agent starts inherits it. Renaming the variable or `connect`'s `--manage` flag also changes both scripts in the same commit.
|
|
79
|
+
|
|
80
|
+
Section 17 of `ARCHITECTURE.md` has the protocol, the retry and terminal outcome table, the `device.json` rules, and the full composition table. The design docs are in the `pinggy_backend` repo under `docs/pinggy-devices/`; read `cli.md` and `api-websocket.md` there before changing anything in `src/devices/`.
|
|
81
|
+
|
|
65
82
|
**Core types** are in `src/types.ts`: `TunnelStatus`, `Status`, `TunnelStateType` enum (`idle/starting/running/live/closed/exited`), `FinalConfig` (extends SDK's `TunnelConfigurationV1`). Browse `src/daemon/` and `src/cli/` for the rest of the module layout.
|
|
66
83
|
|
|
67
84
|
## Subcommands (user-facing)
|
|
@@ -74,6 +91,7 @@ Single daemon per user. State lives under `~/.config/pinggy/` on Linux/macOS or
|
|
|
74
91
|
| `pinggy ps` | Table of running tunnels (ID, name, status, local, URL) |
|
|
75
92
|
| `pinggy attach <name\|id>` | Re-attach TUI to a running tunnel |
|
|
76
93
|
| `pinggy daemon start \| stop \| status \| install-service \| uninstall-service` (alias `d`) | Daemon lifecycle and system-service installation |
|
|
94
|
+
| `pinggy device login \| connect [-b] \| stop \| status \| remove` | Sign this machine in by email, or enrol it with a token, as a Pinggy device; `-b` runs the agent in the daemon, and `stop` stops it there; show the enrolment and the background agent; forget the credential |
|
|
77
95
|
|
|
78
96
|
## Build System
|
|
79
97
|
|