@yunazgr/pi-companion 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.md +335 -2
- package/SECURITY.md +63 -0
- package/package.json +91 -4
- package/src/ask.ts +225 -0
- package/src/bridge.ts +245 -0
- package/src/daemon.ts +221 -0
- package/src/index.ts +229 -0
- package/src/protocol.ts +75 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ygrip
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,336 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Pi Companion
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Lightweight, local-first remote control for Pi sessions.
|
|
4
|
+
|
|
5
|
+
Pi Companion is deliberately not another agent runtime. Pi owns execution and conversation state. A single Rust daemon owns session discovery, pairing, temporary file exchange, browser fan-out, and the embedded web UI.
|
|
6
|
+
|
|
7
|
+
The UI is a static SvelteKit application. There is no Node runtime in production and no Tauri shell. Rust embeds the generated frontend into the daemon binary.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
<p>
|
|
12
|
+
<img src="docs/session-light.webp" alt="Session detail, light theme" width="68%" />
|
|
13
|
+
<img src="docs/mobile.webp" alt="Session detail on a phone" width="28%" />
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
pi install npm:@yunazgr/pi-companion
|
|
19
|
+
|
|
20
|
+
or straight from git:
|
|
21
|
+
|
|
22
|
+
pi install git:github.com/ygrip/pi-companion
|
|
23
|
+
|
|
24
|
+
Then start pi as usual and run /companion in each session you want to follow from the dashboard or your phone. It starts the daemon if needed, prints the dashboard address and shares that session with paired devices (`/companion off` stops sharing). Nothing else to configure: the extension finds or starts the daemon on its own, downloading the matching sha256-verified daemon binary from GitHub Releases on first use (see Daemon).
|
|
25
|
+
|
|
26
|
+
## Architecture
|
|
27
|
+
|
|
28
|
+
local browser :43721
|
|
29
|
+
|
|
|
30
|
+
v
|
|
31
|
+
+---------------------------+
|
|
32
|
+
| pi-companion-server |
|
|
33
|
+
| single Rust daemon |
|
|
34
|
+
| |
|
|
35
|
+
| live session registry |
|
|
36
|
+
| pairing/device registry |
|
|
37
|
+
| per-session temp sandbox |
|
|
38
|
+
+-------------+-------------+
|
|
39
|
+
|
|
|
40
|
+
localhost WebSocket
|
|
41
|
+
+---------+---------+
|
|
42
|
+
v v
|
|
43
|
+
Pi session A Pi session B
|
|
44
|
+
extension extension
|
|
45
|
+
|
|
46
|
+
paired phone
|
|
47
|
+
|
|
|
48
|
+
HTTPS/WSS
|
|
49
|
+
|
|
|
50
|
+
tunnel / reverse proxy
|
|
51
|
+
|
|
|
52
|
+
v
|
|
53
|
+
remote surface :43722
|
|
54
|
+
|
|
|
55
|
+
+---- same daemon
|
|
56
|
+
|
|
57
|
+
The local administration surface and paired-device surface are separate listeners. If you expose Pi Companion through a tunnel, target only port 43722. Never expose port 43721.
|
|
58
|
+
|
|
59
|
+
## Daemon
|
|
60
|
+
|
|
61
|
+
There is exactly one daemon per machine, shared by every Pi session. The extension manages it with no configuration:
|
|
62
|
+
|
|
63
|
+
1. It probes http://127.0.0.1:43721. If anything answers (a daemon started by another session, or one you ran yourself with cargo run), it just connects. It never starts a second copy.
|
|
64
|
+
2. If nothing answers, one Pi session takes a lock (so several sessions starting at once don't race), launches the daemon detached, and waits for it to be ready. The others wait for that daemon.
|
|
65
|
+
3. If a live connection drops, the extension waits 20 seconds before launching a replacement, so restarting a dev daemon doesn't get pre-empted.
|
|
66
|
+
4. The daemon also refuses to run twice: if its ports are taken it prints a message and exits 0.
|
|
67
|
+
|
|
68
|
+
The daemon binary is resolved in this order:
|
|
69
|
+
|
|
70
|
+
| Order | Source |
|
|
71
|
+
|---|---|
|
|
72
|
+
| 1 | PI_COMPANION_SERVER (explicit path override) |
|
|
73
|
+
| 2 | a cargo build inside the package checkout: newest of server/target/release and server/target/debug |
|
|
74
|
+
| 3 | cached download: ~/.pi/agent/pi-companion/bin/<version>/ |
|
|
75
|
+
| 4 | pi-companion-server on PATH |
|
|
76
|
+
| 5 | download of the GitHub release matching the package version, verified against SHA256SUMS, then cached |
|
|
77
|
+
|
|
78
|
+
Optional environment variables (none are required):
|
|
79
|
+
|
|
80
|
+
| Variable | Effect |
|
|
81
|
+
|---|---|
|
|
82
|
+
| PI_COMPANION_SERVER | use this daemon binary |
|
|
83
|
+
| PI_COMPANION_URL | daemon WebSocket URL (default ws://127.0.0.1:43721); autostart only happens for loopback URLs |
|
|
84
|
+
| PI_COMPANION_AUTOSTART=0 | never launch a daemon, only connect |
|
|
85
|
+
| PI_COMPANION_PUBLIC_URL | daemon-side: public URL embedded in pairing QR codes |
|
|
86
|
+
| PI_COMPANION_ALLOWED_ORIGINS | daemon-side: extra comma-separated browser origins accepted besides the loopback addresses and the public URL |
|
|
87
|
+
|
|
88
|
+
Local admin:
|
|
89
|
+
|
|
90
|
+
http://127.0.0.1:43721
|
|
91
|
+
|
|
92
|
+
Paired-device surface:
|
|
93
|
+
|
|
94
|
+
http://127.0.0.1:43722
|
|
95
|
+
|
|
96
|
+
The remote listener is still loopback-only. Use an authenticated HTTPS tunnel or reverse proxy for access outside the machine.
|
|
97
|
+
|
|
98
|
+
Set the externally reachable paired-device URL before starting the daemon:
|
|
99
|
+
|
|
100
|
+
PI_COMPANION_PUBLIC_URL=https://companion.example.com pi-companion-server
|
|
101
|
+
|
|
102
|
+
The configured public URL is used to construct pairing invitations and is the browser origin the paired-device surface accepts (see Security boundary).
|
|
103
|
+
|
|
104
|
+
## Per-session remote control
|
|
105
|
+
|
|
106
|
+
Inside any Pi session:
|
|
107
|
+
|
|
108
|
+
/companion # enable for this session (and print the dashboard address)
|
|
109
|
+
/companion off # stop sharing this session
|
|
110
|
+
/remote-control # toggle sharing on/off
|
|
111
|
+
|
|
112
|
+
A paired device cannot access sessions that have not explicitly enabled remote control.
|
|
113
|
+
|
|
114
|
+
## Questions from Pi
|
|
115
|
+
|
|
116
|
+
Pi's `companion_ask_user` tool asks one to four questions at once. Each question can offer options with descriptions, allow several choices (`multiSelect`), and accept a free-text "Other" answer; a question without options is free text. The browser shows them in a bottom sheet within thumb reach; "Later" hides it until you tap the waiting-question badge.
|
|
117
|
+
|
|
118
|
+
Dialogs from other extensions (`ctx.ui.select`, `ctx.ui.confirm`, `ctx.ui.input`) are relayed to the same sheet while the terminal dialog stays open: whichever side answers first wins and the other closes. Question tools that draw their own `ctx.ui.custom` picker are relayed through a small adapter: pi-jar's `jar_ask` is supported, and a companion answer completes its terminal picker. Other `ctx.ui.custom` components and `ctx.ui.editor` stay terminal-only because they cannot be answered from outside. Pending questions are part of the session snapshot, so a browser that connects later still sees them.
|
|
119
|
+
|
|
120
|
+
## Pairing and devices
|
|
121
|
+
|
|
122
|
+
Pairing is daemon-wide rather than session-specific. Everything lives on the **Devices** page of the local console (port 43721):
|
|
123
|
+
|
|
124
|
+
1. Choose **Pair a device**. The daemon creates a single-use invitation (5 minutes by default, configurable in Settings) and shows it as a QR code, a copyable link and a short code (`XXXX-XXXX`).
|
|
125
|
+
2. On the phone, open the paired-device address. An unpaired device gets a connect screen: scan the QR code with the in-app camera (needs HTTPS; otherwise use the phone's camera app, which opens the invitation link) or type the short code, then give the device a name.
|
|
126
|
+
3. The daemon issues a long random credential and stores only its SHA-256 hash.
|
|
127
|
+
|
|
128
|
+
Typed codes are short, so wrong ones are counted: after ten misses within ten minutes every open invitation is withdrawn and code entry is refused (HTTP 429) until the window passes.
|
|
129
|
+
|
|
130
|
+
Each paired device shows a live **Connected** / **Disconnected** status (with the number of open tabs), when it was last seen and when it was paired. From the console you can:
|
|
131
|
+
|
|
132
|
+
- **Disconnect**: end the device's live connections (WebSocket close code 4001). It stays paired and can reconnect when its user chooses.
|
|
133
|
+
- **Revoke**: delete its credential and drop its connections (close code 4003). The device clears its stored credential and must scan a new code.
|
|
134
|
+
|
|
135
|
+
Paired devices (name, browser user agent, pairing and last-seen times, credential hash) and settings survive daemon restarts. They are stored in `~/.pi/agent/pi-companion/state.json`, created with mode 0600 inside a 0700 directory and replaced atomically; a looser mode found on startup is tightened. Override the directory with `PI_COMPANION_HOME`.
|
|
136
|
+
|
|
137
|
+
A phone pairs once with the daemon. Individual Pi sessions still opt in using /companion.
|
|
138
|
+
|
|
139
|
+
## Settings
|
|
140
|
+
|
|
141
|
+
The **Settings** page (local console only) covers:
|
|
142
|
+
|
|
143
|
+
| Setting | Default | Notes |
|
|
144
|
+
|---|---|---|
|
|
145
|
+
| Theme | System | Light, dark or follow the OS. Stored per browser. |
|
|
146
|
+
| Public address | empty | The tunnel / reverse-proxy URL embedded in pairing codes. `PI_COMPANION_PUBLIC_URL` overrides it and locks the field. |
|
|
147
|
+
| Pairing code lifetime | 5 minutes | 1 to 60. |
|
|
148
|
+
| Largest file | 25 MB | 1 to 100 MB per upload. |
|
|
149
|
+
| Allowed file types | any | Optional MIME allowlist such as `image/*, application/pdf`. Uploads outside it get HTTP 415. |
|
|
150
|
+
|
|
151
|
+
It also shows the daemon version, both addresses, and where settings and session files are stored.
|
|
152
|
+
|
|
153
|
+
## Temporary files
|
|
154
|
+
|
|
155
|
+
Each session has an isolated temporary directory beneath the operating system temp directory:
|
|
156
|
+
|
|
157
|
+
<temp>/pi-companion/<session-id>/
|
|
158
|
+
|
|
159
|
+
On Unix the daemon attempts to set the Pi Companion and session directories to mode 0700.
|
|
160
|
+
|
|
161
|
+
The browser can upload a file from the Files tab. Current constraints:
|
|
162
|
+
|
|
163
|
+
- 25 MiB maximum per upload by default (Settings); a rejected or interrupted upload leaves no partial file
|
|
164
|
+
- optional MIME allowlist: the type guessed from the file name and any specific declared Content-Type must both be allowed
|
|
165
|
+
- session ids in paths are limited to `[A-Za-z0-9_-]`, so they can never name a directory outside the sandbox
|
|
166
|
+
- filename is reduced to its final path component
|
|
167
|
+
- generated opaque file id prefixes the on-disk name
|
|
168
|
+
- the daemon never accepts a client-provided destination path
|
|
169
|
+
- file deletion accepts only the opaque file id
|
|
170
|
+
- deletion verifies the stored path is still inside that session sandbox
|
|
171
|
+
- sandbox content is removed 30 seconds after a disconnected session fails to reconnect
|
|
172
|
+
|
|
173
|
+
The Pi extension exposes:
|
|
174
|
+
|
|
175
|
+
companion_temp_files
|
|
176
|
+
|
|
177
|
+
which returns the sandbox file paths available to the agent, and:
|
|
178
|
+
|
|
179
|
+
companion_delete_temp_file
|
|
180
|
+
|
|
181
|
+
which destroys one temporary file by opaque id.
|
|
182
|
+
|
|
183
|
+
This means the agent can consume uploaded artifacts using its normal file capabilities while Pi Companion retains ownership of upload placement and cleanup.
|
|
184
|
+
|
|
185
|
+
## Session identity
|
|
186
|
+
|
|
187
|
+
Every registered Pi session publishes a compact display model to the daemon:
|
|
188
|
+
|
|
189
|
+
- session name
|
|
190
|
+
- short title
|
|
191
|
+
- status: active, idle, or stopped
|
|
192
|
+
- main model
|
|
193
|
+
- thinking effort
|
|
194
|
+
- working directory and process id
|
|
195
|
+
- remote-control state
|
|
196
|
+
|
|
197
|
+
Stopped sessions remain visible in the daemon registry so the dashboard does not lose context when a Pi process exits. Their controls are disabled and their temporary sandbox is cleaned after the reconnect grace period.
|
|
198
|
+
|
|
199
|
+
## UI
|
|
200
|
+
|
|
201
|
+
A single-page SvelteKit app, embedded in the daemon binary:
|
|
202
|
+
|
|
203
|
+
| Page | Who sees it | What it is for |
|
|
204
|
+
|---|---|---|
|
|
205
|
+
| Overview | everyone | Dot-field hero, live counts, sessions that need your answer, live sessions, Help entry |
|
|
206
|
+
| Sessions | everyone | Searchable, filterable list (Working / Waiting / Ended). Paired devices only see shared sessions |
|
|
207
|
+
| Session detail | everyone | Shell-style transcript that follows the theme: Markdown replies with highlight.js code and Mermaid diagrams, collapsible tool output and thinking. Question sheet, files, git changes, /plan; the composer (message / steer) appears on Activity only. Session details are collapsed under the title |
|
|
208
|
+
| Devices, Settings | local console only | Pairing, connection status, disconnect and revoke; daemon settings with a save bar that appears only for unsaved edits |
|
|
209
|
+
| Help, Privacy, Terms | everyone | Setup stepper, commands, tools, troubleshooting; data handling; terms of use. Help is opened from the Overview |
|
|
210
|
+
|
|
211
|
+
The Live indicator in the header and sidebar opens the connection sheet (status, version and, on this computer, the console and device addresses and data folders).
|
|
212
|
+
|
|
213
|
+
Git problems on the Changes tab (for example a folder that is not a git repository) show as a toast, never in the transcript.
|
|
214
|
+
|
|
215
|
+
Design notes:
|
|
216
|
+
|
|
217
|
+
- light and dark themes with a system default, applied before first paint so there is no flash
|
|
218
|
+
- claymorphism: solid surfaces with an outer drop shadow plus inset highlight and shade (no blur or glass); small icons are Lucide line icons on raised clay wells, feature art is 3D clay renders from [3dicons](https://3dicons.co) (CC0)
|
|
219
|
+
- warm amber on graphite or paper, matching the logo; system fonts only, so nothing loads from the network. highlight.js and Mermaid load only when a reply contains code or a diagram
|
|
220
|
+
- the window never scrolls. The sidebar, page content, activity feed, file list, diff, chips and tabs each scroll inside their own container, and the composer and mobile tab bar stay put
|
|
221
|
+
- the activity feed follows new output and stops following when you scroll up ("Jump to latest" brings you back)
|
|
222
|
+
- mobile: bottom tab bar, safe-area insets, 44px touch targets, 16px inputs (no iOS zoom), Enter inserts a newline on touch keyboards
|
|
223
|
+
- accessibility: skip link, visible focus rings, arrow-key tabs, labelled controls, live regions for the feed and questions, reduced-motion support
|
|
224
|
+
- the dot field is Raksara's component, loaded when the browser is idle, paused when hidden, and static under reduced motion
|
|
225
|
+
- dropping a file anywhere outside the Files drop zone is ignored. Without this, the browser tries to open the file itself (Firefox reports this as "may not load or link to file:///")
|
|
226
|
+
|
|
227
|
+
### Caching and updates
|
|
228
|
+
|
|
229
|
+
The UI is built with `adapter-static` in SPA mode (`200.html` fallback) and embedded with `rust-embed`:
|
|
230
|
+
|
|
231
|
+
- `/_app/immutable/**` is content-hashed and served with `Cache-Control: public, max-age=31536000, immutable`
|
|
232
|
+
- the shell, `version.json` and icons are served with `no-cache` and a strong ETag, so a reload costs a 304 until something changes
|
|
233
|
+
- unknown file-like paths return 404 instead of the HTML shell, so a stale tab never parses HTML as JavaScript
|
|
234
|
+
- the app polls `version.json` every minute; when a new build is detected it shows a reload banner and the next navigation loads the new version
|
|
235
|
+
- HTML responses carry a same-origin Content-Security-Policy and `X-Frame-Options: DENY`
|
|
236
|
+
|
|
237
|
+
Generated `server/web-dist` files are not committed. CI builds them once and every native build embeds the same bundle.
|
|
238
|
+
|
|
239
|
+
## Browser controls
|
|
240
|
+
|
|
241
|
+
The initial slice supports:
|
|
242
|
+
|
|
243
|
+
- multiple live Pi sessions
|
|
244
|
+
- prompt
|
|
245
|
+
- steer
|
|
246
|
+
- abort
|
|
247
|
+
- /plan
|
|
248
|
+
- git diff
|
|
249
|
+
- streamed lifecycle/tool/message events
|
|
250
|
+
- companion ask-user requests
|
|
251
|
+
- temporary file upload/list/delete
|
|
252
|
+
- paired-device management
|
|
253
|
+
- per-session remote-control opt-in
|
|
254
|
+
|
|
255
|
+
There is deliberately no arbitrary shell, arbitrary tool invocation, or arbitrary filesystem API.
|
|
256
|
+
|
|
257
|
+
## Development
|
|
258
|
+
|
|
259
|
+
Requirements: Node 22.17+ and stable Rust.
|
|
260
|
+
|
|
261
|
+
npm install
|
|
262
|
+
npm run serve
|
|
263
|
+
|
|
264
|
+
This builds the UI, starts the daemon and prints where it is listening:
|
|
265
|
+
|
|
266
|
+
Pi Companion v0.2.1
|
|
267
|
+
|
|
268
|
+
Console http://127.0.0.1:43721
|
|
269
|
+
Paired devices http://127.0.0.1:43722
|
|
270
|
+
Pairing links http://127.0.0.1:43722 (default, this computer only; set one in Settings)
|
|
271
|
+
Data ~/.pi/agent/pi-companion (0 paired)
|
|
272
|
+
|
|
273
|
+
If a daemon is already running (for example one a Pi session started automatically), `npm run serve` prints its console address and exits instead of starting a second copy. Stop the running one first (`pkill -f pi-companion-server`) if you want your fresh build to take over. Set `RUST_LOG` (for example `RUST_LOG=debug`) for more detailed logs.
|
|
274
|
+
|
|
275
|
+
Type checks cover the extension (`tsc`) and the UI (`svelte-check`, warnings fail):
|
|
276
|
+
|
|
277
|
+
npm run check
|
|
278
|
+
|
|
279
|
+
Tests cover the question relay (`node --test`) and the daemon's wire protocol, pairing expiry and code throttling, origin policy, bridge and device reconnects, path traversal, upload limits, MIME allowlist, state-file permissions and multi-session isolation (`cargo test`):
|
|
280
|
+
|
|
281
|
+
npm test
|
|
282
|
+
|
|
283
|
+
UI modules import shared code through the `#lib/*` subpath import declared in `ui/package.json` (SvelteKit 3 replaced `$lib`). Subpath imports do not guess extensions, so always write the full file name: `#lib/format.ts`, `#lib/companion.svelte.ts`, `#lib/Icon.svelte`. `ui/tsconfig.json` extends SvelteKit's generated `$app/tsconfig`.
|
|
284
|
+
|
|
285
|
+
Then, in another shell, start Pi with the extension from this checkout:
|
|
286
|
+
|
|
287
|
+
pi -e ./src/index.ts
|
|
288
|
+
|
|
289
|
+
For hot-reloading UI work, keep the daemon running and use:
|
|
290
|
+
|
|
291
|
+
npm run ui:dev
|
|
292
|
+
|
|
293
|
+
which proxies /api and /ws to the daemon on 43721.
|
|
294
|
+
|
|
295
|
+
Running the daemon manually with npm run serve and the extension side by side just works: the extension sees the running daemon and connects to it. If you don't run it, the extension launches your local cargo build automatically.
|
|
296
|
+
|
|
297
|
+
## Releases
|
|
298
|
+
|
|
299
|
+
The package carries the required `pi-package` keyword, which makes the published npm package eligible for discovery in the Pi package gallery (https://pi.dev/packages). The manifest also includes focused discovery keywords such as `pi-extension`, `pi-coding-agent`, `remote-control`, and `session-dashboard`. Host-provided packages (@earendil-works/pi-coding-agent, typebox) are peer dependencies, as Pi requires.
|
|
300
|
+
|
|
301
|
+
GitHub Actions run only for release tags. Pushing ordinary commits or opening pull requests does not trigger any workflow; CI can also be started manually with workflow_dispatch.
|
|
302
|
+
|
|
303
|
+
A tag matching vX.Y.Z triggers both the CI and release workflows.
|
|
304
|
+
|
|
305
|
+
Before publishing, the workflow requires X.Y.Z to match both package.json and server/Cargo.toml. It then runs TypeScript and Rust checks, builds native daemon archives for Linux x86_64, macOS arm64, macOS x86_64, and Windows x86_64, generates SHA-256 checksums, and creates a GitHub Release.
|
|
306
|
+
|
|
307
|
+
Only after the GitHub Release exists, the same tag publishes @yunazgr/pi-companion to npm with provenance through npm Trusted Publishing (GitHub OIDC). No long-lived NPM_TOKEN is required. The trusted publisher should point to GitHub owner ygrip, repository pi-companion, workflow release.yml. Git installs need no npm credential.
|
|
308
|
+
|
|
309
|
+
On first use the extension downloads `pi-companion-server-<os>-<arch>` for its own package version (`releases/download/v<version>/`), verifies it against `SHA256SUMS`, and caches it under `~/.pi/agent/pi-companion/bin/<version>/`. A git install from a branch that is ahead of the newest tag falls back to the latest release.
|
|
310
|
+
|
|
311
|
+
## Security boundary
|
|
312
|
+
|
|
313
|
+
The design follows a few non-negotiable rules:
|
|
314
|
+
|
|
315
|
+
- local admin and remote device surfaces use different ports
|
|
316
|
+
- both listeners bind to loopback
|
|
317
|
+
- only the remote listener should ever sit behind a tunnel
|
|
318
|
+
- pairing invitations are single-use and expire after five minutes; wrong short codes are throttled
|
|
319
|
+
- paired-device secrets are stored only as SHA-256 hashes, in a 0600 state file
|
|
320
|
+
- strict Origin checks: the paired-device surface accepts WebSocket upgrades and POST/DELETE only from its own loopback address, the public URL or `PI_COMPANION_ALLOWED_ORIGINS`, and refuses any foreign Origin; the console accepts only its own loopback origin, so a web page cannot drive Pi through it
|
|
321
|
+
- remote devices only see sessions with remoteEnabled=true
|
|
322
|
+
- uploads are placed only in daemon-created session sandboxes, within the size limit and optional MIME allowlist
|
|
323
|
+
- file deletion is id-based and boundary checked
|
|
324
|
+
- there is no generic shell endpoint
|
|
325
|
+
- Pi remains the authority that performs agent actions
|
|
326
|
+
|
|
327
|
+
## Next hardening milestones
|
|
328
|
+
|
|
329
|
+
1. Add per-device session permissions in addition to session opt-in.
|
|
330
|
+
2. Bound browser event queues by both item count and bytes, dropping low-priority deltas first.
|
|
331
|
+
3. Add upload count/quota limits per session.
|
|
332
|
+
4. Package signed/notarized daemon binaries and service installation for macOS/Linux/Windows.
|
|
333
|
+
|
|
334
|
+
## License
|
|
335
|
+
|
|
336
|
+
MIT. See [LICENSE](LICENSE).
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Security model
|
|
2
|
+
|
|
3
|
+
Pi Companion intentionally exposes less power than Pi itself.
|
|
4
|
+
|
|
5
|
+
## Trust zones
|
|
6
|
+
|
|
7
|
+
### Local admin surface
|
|
8
|
+
|
|
9
|
+
127.0.0.1:43721
|
|
10
|
+
|
|
11
|
+
Trusted local administrator only. It can create pairing invitations, see which paired devices are connected, disconnect or revoke them, change daemon settings, and inspect all registered sessions.
|
|
12
|
+
|
|
13
|
+
Do not place this listener behind a tunnel or reverse proxy.
|
|
14
|
+
|
|
15
|
+
It has no credentials, so it refuses any request whose `Origin` is not its own loopback address (`http://127.0.0.1:43721`, `localhost`, `[::1]`) or listed in `PI_COMPANION_ALLOWED_ORIGINS`. A web page on another origin can therefore not open its WebSocket and steer Pi. Requests without `Origin` (the Pi bridge, CLI tools) are accepted.
|
|
16
|
+
|
|
17
|
+
### Paired-device surface
|
|
18
|
+
|
|
19
|
+
127.0.0.1:43722
|
|
20
|
+
|
|
21
|
+
Designed to sit behind an HTTPS/WSS tunnel. Every control/data endpoint requires a paired-device credential, except the one-time pairing claim endpoint and static pairing UI.
|
|
22
|
+
|
|
23
|
+
Strict Origin checks: WebSocket upgrades and POST/DELETE requests must carry an `Origin` equal to the loopback device address, the configured public URL, or an entry of `PI_COMPANION_ALLOWED_ORIGINS`; GET requests may omit `Origin` but are refused with any foreign one.
|
|
24
|
+
|
|
25
|
+
Pairing invitations are single-use and expire (5 minutes by default). The short typed code is rate limited: ten wrong codes within ten minutes withdraw every open invitation and further code claims get HTTP 429 until the window passes.
|
|
26
|
+
|
|
27
|
+
Paired devices can see only sessions that explicitly enabled remote control.
|
|
28
|
+
|
|
29
|
+
Revoking a device deletes its credential hash and closes its open connections immediately (WebSocket close 4003). Disconnecting closes them without deleting the credential (close 4001).
|
|
30
|
+
|
|
31
|
+
### Stored state
|
|
32
|
+
|
|
33
|
+
Paired devices (name, browser user agent, pairing and last-seen times, and the SHA-256 hash of the credential; never the credential) and settings are written atomically to `~/.pi/agent/pi-companion/state.json`. On Unix the directory is 0700 and the file is created 0600 before any byte is written; a looser mode found at startup is tightened.
|
|
34
|
+
|
|
35
|
+
### Web UI hardening
|
|
36
|
+
|
|
37
|
+
HTML responses send a same-origin Content-Security-Policy (no third-party scripts, styles, fonts or connections), `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` and `X-Content-Type-Options: nosniff`. The UI loads nothing from the network beyond the daemon itself.
|
|
38
|
+
|
|
39
|
+
### Pi bridge
|
|
40
|
+
|
|
41
|
+
Each Pi process keeps one outbound localhost WebSocket to the daemon. The browser cannot invoke arbitrary extension tools. The allowed command set is a closed protocol.
|
|
42
|
+
|
|
43
|
+
## Files
|
|
44
|
+
|
|
45
|
+
Uploads are daemon-owned and session-scoped.
|
|
46
|
+
|
|
47
|
+
The browser cannot choose a destination path. The daemon derives a final filename (last path component, no control characters or leading dots), prefixes it with an opaque id, and places it in the current session sandbox. Session ids that reach the filesystem are restricted to `[A-Za-z0-9_-]`.
|
|
48
|
+
|
|
49
|
+
Uploads above the configured size limit are refused (HTTP 413) and their partial file is removed. An optional MIME allowlist (Settings) refuses other types with HTTP 415; both the type implied by the file name and any specific declared Content-Type must be allowed.
|
|
50
|
+
|
|
51
|
+
Agent deletion uses the opaque id rather than a path. Before unlinking, the daemon verifies that the recorded file is still beneath the expected session sandbox.
|
|
52
|
+
|
|
53
|
+
## Non-goals
|
|
54
|
+
|
|
55
|
+
Pi Companion does not provide:
|
|
56
|
+
|
|
57
|
+
- remote shell access
|
|
58
|
+
- arbitrary filesystem browsing
|
|
59
|
+
- arbitrary tool invocation
|
|
60
|
+
- direct git push/commit endpoints
|
|
61
|
+
- a second copy of Pi conversation state
|
|
62
|
+
|
|
63
|
+
These should remain Pi actions, where Pi's existing policies and agent loop remain in control.
|
package/package.json
CHANGED
|
@@ -1,6 +1,93 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yunazgr/pi-companion",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Local-first remote control for Pi sessions: live activity feed, steering, git diff, file drop and phone pairing from a single Rust daemon.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"pi-coding-agent",
|
|
9
|
+
"pi",
|
|
10
|
+
"remote-control",
|
|
11
|
+
"remote-agent",
|
|
12
|
+
"companion",
|
|
13
|
+
"session-dashboard",
|
|
14
|
+
"developer-tools",
|
|
15
|
+
"mobile"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://github.com/ygrip/pi-companion#readme",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/ygrip/pi-companion.git"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/ygrip/pi-companion/issues"
|
|
24
|
+
},
|
|
25
|
+
"author": "ygrip <yunaz.gilang@gmail.com>",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"type": "module",
|
|
28
|
+
"main": "src/index.ts",
|
|
29
|
+
"files": [
|
|
30
|
+
"src",
|
|
31
|
+
"README.md",
|
|
32
|
+
"SECURITY.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22.17"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"serve": "npm run --silent ui:build -- --logLevel warn && cargo run -q --manifest-path server/Cargo.toml",
|
|
40
|
+
"server:dev": "npm run serve",
|
|
41
|
+
"server:check": "npm run ui:build && cargo check --manifest-path server/Cargo.toml",
|
|
42
|
+
"ui:dev": "cd ui && vite dev",
|
|
43
|
+
"ui:build": "cd ui && vite build",
|
|
44
|
+
"check": "tsc --noEmit && npm run ui:check",
|
|
45
|
+
"ui:check": "cd ui && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings",
|
|
46
|
+
"test": "npm run test:extension && npm run test:server",
|
|
47
|
+
"test:extension": "node --test --experimental-transform-types --no-warnings test/*.test.ts",
|
|
48
|
+
"test:server": "cargo test --manifest-path server/Cargo.toml"
|
|
49
|
+
},
|
|
50
|
+
"pi": {
|
|
51
|
+
"extensions": [
|
|
52
|
+
"./src/index.ts"
|
|
53
|
+
],
|
|
54
|
+
"image": "https://raw.githubusercontent.com/ygrip/pi-companion/main/docs/dashboard.png"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"ws": "^8.18.3"
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
61
|
+
"typebox": "*"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
65
|
+
"@sveltejs/adapter-static": "^4.0.0",
|
|
66
|
+
"@sveltejs/kit": "^3.0.1",
|
|
67
|
+
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
68
|
+
"@types/node": "^24.7.0",
|
|
69
|
+
"@types/ws": "^8.18.1",
|
|
70
|
+
"dompurify": "^3.4.16",
|
|
71
|
+
"highlight.js": "^11.12.0",
|
|
72
|
+
"jsqr": "^1.4.0",
|
|
73
|
+
"marked": "^18.1.0",
|
|
74
|
+
"mermaid": "^12.1.0",
|
|
75
|
+
"svelte": "^5.57.2",
|
|
76
|
+
"svelte-check": "^4.7.6",
|
|
77
|
+
"typebox": "^1.0.13",
|
|
78
|
+
"typescript": "^6.0.0",
|
|
79
|
+
"vite": "^8.0.12"
|
|
80
|
+
},
|
|
81
|
+
"overrides": {
|
|
82
|
+
"node-domexception": "file:./tools/shims/node-domexception"
|
|
83
|
+
},
|
|
84
|
+
"allowScripts": {
|
|
85
|
+
"esbuild": true,
|
|
86
|
+
"@google/genai": false,
|
|
87
|
+
"protobufjs": false,
|
|
88
|
+
"fsevents": false
|
|
89
|
+
},
|
|
90
|
+
"publishConfig": {
|
|
91
|
+
"access": "public"
|
|
92
|
+
}
|
|
93
|
+
}
|