@timqi/pier 0.0.15 → 0.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/dist/agent/pi.js +13 -3
- package/dist/db.js +61 -0
- package/dist/extensions/web/content.js +5 -0
- package/dist/extensions/web/language.js +5 -0
- package/dist/extensions/web/tools.js +5 -0
- package/dist/settings.js +2 -11
- package/dist/tasks/agent.js +5 -0
- package/dist/tasks/command.js +15 -0
- package/dist/tasks/definitions.js +4 -0
- package/dist/tasks/execution.js +4 -0
- package/dist/tasks/groups.js +4 -0
- package/dist/tasks/messages.js +6 -0
- package/dist/tasks/routes.js +4 -0
- package/dist/tasks/runs.js +5 -0
- package/dist/tasks/service.js +6 -0
- package/dist/tasks/store.js +4 -0
- package/dist/tasks/types.js +4 -0
- package/dist/tools.js +10 -16
- package/dist/web/auth.js +215 -65
- package/dist/web/public/assets/{ghostty-web-xcUrfRRs.js → ghostty-web-CV7F9_iW.js} +1 -1
- package/dist/web/public/assets/index-CtR-Ob4T.css +2 -0
- package/dist/web/public/assets/index-DAxKtidH.js +93 -0
- package/dist/web/public/index.html +2 -2
- package/dist/web/push.js +30 -7
- package/dist/web/terminal.js +19 -9
- package/docs/deploy.md +24 -6
- package/package.json +1 -1
- package/dist/web/public/assets/index-BWDlAMK2.js +0 -93
- package/dist/web/public/assets/index-DHqZnZr7.css +0 -2
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
which style.css pays for with the safe-area inset. -->
|
|
34
34
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
35
35
|
<title>Pier</title>
|
|
36
|
-
<script type="module" crossorigin src="/assets/index-
|
|
37
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
36
|
+
<script type="module" crossorigin src="/assets/index-DAxKtidH.js"></script>
|
|
37
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CtR-Ob4T.css">
|
|
38
38
|
</head>
|
|
39
39
|
<!-- The document never scrolls: this is a fixed-viewport workbench, and every
|
|
40
40
|
scrollable region is an inner pane. h-dvh, not h-screen, because 100vh can
|
package/dist/web/push.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { readableTitle } from "../core/identity.js";
|
|
11
11
|
import { pierDb } from "../db.js";
|
|
12
12
|
import { logger } from "../log.js";
|
|
13
|
+
import { sessionIdOf } from "./auth.js";
|
|
13
14
|
import { generateVapidKeys, sendPush, } from "./webpush.js";
|
|
14
15
|
const log = logger("push");
|
|
15
16
|
/** Devices kept at once. A browser mints a new subscription whenever the old
|
|
@@ -29,6 +30,10 @@ const MAX_BODY_CHARS = 160;
|
|
|
29
30
|
* browser bug rather than as an unnamed session — and a listing that could
|
|
30
31
|
* not answer must not silence the push. */
|
|
31
32
|
const label = (s) => readableTitle(s?.title) || s?.cwd.split("/").filter(Boolean).at(-1) || "Pier session";
|
|
33
|
+
/** SQLite's "that parent row does not exist" — the session ended. Anything
|
|
34
|
+
* else that goes wrong here is a broken database, not a signed-out browser. */
|
|
35
|
+
const FOREIGN_KEY_VIOLATION = 787;
|
|
36
|
+
const sessionGone = (err) => err.errcode === FOREIGN_KEY_VIOLATION;
|
|
32
37
|
/** Subscriptions and the instance's VAPID identity. Both are per-instance
|
|
33
38
|
* facts nobody edits by hand, so they live beside every other one. */
|
|
34
39
|
export class PushStore {
|
|
@@ -59,14 +64,16 @@ export class PushStore {
|
|
|
59
64
|
.all();
|
|
60
65
|
}
|
|
61
66
|
/** Upsert: a browser re-posts the same subscription on every load, which is
|
|
62
|
-
* what repairs a row this instance lost
|
|
63
|
-
|
|
67
|
+
* what repairs a row this instance lost — and what re-attaches one to the
|
|
68
|
+
* session that is signed in now. */
|
|
69
|
+
save(target, label, sessionId) {
|
|
64
70
|
this.#db
|
|
65
|
-
.prepare(`INSERT INTO push_subscriptions(endpoint, p256dh, auth, label, created_at)
|
|
66
|
-
VALUES (?, ?, ?, ?, ?)
|
|
71
|
+
.prepare(`INSERT INTO push_subscriptions(endpoint, p256dh, auth, label, created_at, session_id)
|
|
72
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
67
73
|
ON CONFLICT(endpoint) DO UPDATE SET
|
|
68
|
-
p256dh = excluded.p256dh, auth = excluded.auth, label = excluded.label
|
|
69
|
-
|
|
74
|
+
p256dh = excluded.p256dh, auth = excluded.auth, label = excluded.label,
|
|
75
|
+
session_id = excluded.session_id`)
|
|
76
|
+
.run(target.endpoint, target.p256dh, target.auth, label, Date.now(), sessionId);
|
|
70
77
|
this.#db
|
|
71
78
|
.prepare(`DELETE FROM push_subscriptions WHERE endpoint NOT IN
|
|
72
79
|
(SELECT endpoint FROM push_subscriptions ORDER BY created_at DESC LIMIT ?)`)
|
|
@@ -222,7 +229,23 @@ export function registerPushRoutes(app, deps) {
|
|
|
222
229
|
if (!target)
|
|
223
230
|
return c.json({ error: "not a push subscription" }, 400);
|
|
224
231
|
const label = String(body.label ?? "a browser").slice(0, 80);
|
|
225
|
-
|
|
232
|
+
// The session that is asking owns the subscription, and the foreign key is
|
|
233
|
+
// what makes signing this browser out take the subscription with it. It can
|
|
234
|
+
// refuse: the boundary let this request in and the browser was signed out
|
|
235
|
+
// while its body was still arriving. Say so rather than 500 — the browser
|
|
236
|
+
// is about to be sent to the login form by its next request anyway.
|
|
237
|
+
try {
|
|
238
|
+
store.save(target, label, sessionIdOf(c));
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
// Only that. A full or read-only database answering 401 would send a
|
|
242
|
+
// signed-in browser to the login form, where the password it types will
|
|
243
|
+
// not help either.
|
|
244
|
+
if (!sessionGone(err))
|
|
245
|
+
throw err;
|
|
246
|
+
log.warn(`subscription refused for a session that ended: ${String(err)}`);
|
|
247
|
+
return c.json({ error: "session ended" }, 401);
|
|
248
|
+
}
|
|
226
249
|
log.info(`subscribed ${label}`);
|
|
227
250
|
return c.json({ ok: true }, 201);
|
|
228
251
|
});
|
package/dist/web/terminal.js
CHANGED
|
@@ -11,7 +11,7 @@ import { isAbsolute } from "node:path";
|
|
|
11
11
|
import { spawn } from "node-pty";
|
|
12
12
|
import { WebSocketServer } from "ws";
|
|
13
13
|
import { logger } from "../log.js";
|
|
14
|
-
import { upgradeAuthorized } from "./auth.js";
|
|
14
|
+
import { ALL, upgradeAuthorized } from "./auth.js";
|
|
15
15
|
const log = logger("terminal");
|
|
16
16
|
/** Live shells at once — a guard against forgotten spawns, not a quota. */
|
|
17
17
|
const MAX_TERMS = 8;
|
|
@@ -276,10 +276,18 @@ export function attachTerminal(server, auth, opts = {}) {
|
|
|
276
276
|
});
|
|
277
277
|
server.once("close", () => hub.close());
|
|
278
278
|
wss.on("error", (err) => log.error("terminal WebSocket server failed", err));
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
279
|
+
// A shell outlives the request that opened it, so revocation has to reach it
|
|
280
|
+
// here: the session that opened this socket was signed out (or every session
|
|
281
|
+
// was, by a password change), and the terminal goes with it.
|
|
282
|
+
const sessionOf = new WeakMap();
|
|
283
|
+
const revoked = new WeakSet();
|
|
284
|
+
auth.onRevoke((id) => {
|
|
285
|
+
for (const client of wss.clients) {
|
|
286
|
+
if (id !== ALL && sessionOf.get(client) !== id)
|
|
287
|
+
continue;
|
|
288
|
+
revoked.add(client);
|
|
289
|
+
client.close(1008, "signed out");
|
|
290
|
+
}
|
|
283
291
|
});
|
|
284
292
|
server.on("upgrade", (req, socket, head) => {
|
|
285
293
|
let url;
|
|
@@ -296,14 +304,15 @@ export function attachTerminal(server, auth, opts = {}) {
|
|
|
296
304
|
socket.destroy();
|
|
297
305
|
return;
|
|
298
306
|
}
|
|
299
|
-
|
|
307
|
+
const sessionId = upgradeAuthorized(auth, req);
|
|
308
|
+
if (!sessionId) {
|
|
300
309
|
log.warn("refused terminal upgrade (unauthorized)");
|
|
301
310
|
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
302
311
|
socket.destroy();
|
|
303
312
|
return;
|
|
304
313
|
}
|
|
305
|
-
const authKey = auth.cookieKey;
|
|
306
314
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
315
|
+
sessionOf.set(ws, sessionId);
|
|
307
316
|
alive.add(ws);
|
|
308
317
|
ws.on("pong", () => alive.add(ws));
|
|
309
318
|
// Frames can land while attach() is resolving realpath; hold a bounded
|
|
@@ -320,9 +329,10 @@ export function attachTerminal(server, auth, opts = {}) {
|
|
|
320
329
|
ws.on("message", (data, binary) => {
|
|
321
330
|
if (closed)
|
|
322
331
|
return;
|
|
323
|
-
|
|
332
|
+
// A frame can already be in flight when the close above goes out.
|
|
333
|
+
if (revoked.has(ws)) {
|
|
324
334
|
closed = true;
|
|
325
|
-
ws.close(1008, "
|
|
335
|
+
ws.close(1008, "signed out");
|
|
326
336
|
return;
|
|
327
337
|
}
|
|
328
338
|
if (binary) {
|
package/docs/deploy.md
CHANGED
|
@@ -177,9 +177,9 @@ journalctl --user -u pier --since -1h | grep 'tasks:' # one area
|
|
|
177
177
|
```
|
|
178
178
|
|
|
179
179
|
Every line is `area: message` — `core`, `agent`, `tasks`, `slack`, `telegram`,
|
|
180
|
-
`channels`, `slack.tool`, `auth`, `boards`, `client`, `db`, `drain`,
|
|
181
|
-
`settings`, `credentials`, `update`, `
|
|
182
|
-
grep
|
|
180
|
+
`lark`, `channels`, `slack.tool`, `auth`, `boards`, `client`, `db`, `drain`,
|
|
181
|
+
`secrets`, `settings`, `credentials`, `update`, `tools`, `terminal`, `push`,
|
|
182
|
+
`web`, `web.providers`, `pier` — so an area is a grep
|
|
183
183
|
and a level is a `-p`. The level reaches journald as a syslog priority prefix, which Pier
|
|
184
184
|
emits only when systemd says the output is a journal (`$JOURNAL_STREAM`); run
|
|
185
185
|
in a terminal, the same lines carry a timestamp and a level word instead.
|
|
@@ -229,14 +229,23 @@ sqlite3 ~/.pier/db/pier.db 'DELETE FROM auth'
|
|
|
229
229
|
pier restart
|
|
230
230
|
```
|
|
231
231
|
|
|
232
|
-
Changing the password
|
|
233
|
-
|
|
232
|
+
Changing the password signs out every browser: the session rows behind the
|
|
233
|
+
cookies are deleted. So does the recovery above — a new password does not leave
|
|
234
|
+
the old one's browsers signed in. To end one browser without changing the
|
|
235
|
+
password, use Settings → Instance → Signed-in devices. A session that is not
|
|
236
|
+
used expires 7 days after its last request. However a session ends — signed
|
|
237
|
+
out, expired, password changed, password recovered — its push subscription is
|
|
238
|
+
deleted with it, so that browser stops being notified as well.
|
|
239
|
+
|
|
240
|
+
The upgrade that introduced these rows signs everyone out once: a cookie issued
|
|
241
|
+
before it names no row. Have the password to hand.
|
|
234
242
|
|
|
235
243
|
## Restarting and reloading
|
|
236
244
|
|
|
237
245
|
```sh
|
|
238
246
|
pier restart # finish active work, then restart the service
|
|
239
247
|
pier reload # apply channel config and recycle idle sessions in place
|
|
248
|
+
pier tools sync # install/update the managed CLI tools
|
|
240
249
|
```
|
|
241
250
|
|
|
242
251
|
Both commands signal the installed systemd service; they are not foreground
|
|
@@ -246,6 +255,14 @@ next process. At the deadline it records every aborted IM turn first, and the
|
|
|
246
255
|
next process posts that note after its adapter starts. Cleanup after the deadline
|
|
247
256
|
has one shared 10-second bound regardless of how many sessions are stuck.
|
|
248
257
|
|
|
258
|
+
`pier tools sync` converges the command-line tools switched on in Console →
|
|
259
|
+
Settings — they install into `~/.pier/tools/bin`, which the service puts first
|
|
260
|
+
on the PATH every session, task and terminal inherits. Normally a switch runs
|
|
261
|
+
it for you as a task; typing it is for a machine that was offline when one was
|
|
262
|
+
flipped. One sync runs at a time per machine: an overlapping one waits for the
|
|
263
|
+
lock (and converges on the switches as they stand when its turn comes) instead
|
|
264
|
+
of racing the other's installs.
|
|
265
|
+
|
|
249
266
|
`pier reload` does not stop active work. It reloads Slack and Telegram adapters
|
|
250
267
|
and immediately evicts idle sessions nobody is watching, so their next message
|
|
251
268
|
opens with current agent files and configuration. Streaming sessions and
|
|
@@ -391,7 +408,8 @@ elsewhere, pick a tunnel rather than a wider bind:
|
|
|
391
408
|
there, preserve the external `Host` (or pass `X-Forwarded-Host`), and pass
|
|
392
409
|
`X-Forwarded-For`; Pier uses the external host for write-origin checks and
|
|
393
410
|
counts login failures per forwarded client. Its session cookie is marked
|
|
394
|
-
`Secure` when
|
|
411
|
+
`Secure` when a loopback proxy reports `X-Forwarded-Proto: https` (the header
|
|
412
|
+
is ignored from anywhere else, where it is a header the client wrote). The proxy must
|
|
395
413
|
pass WebSocket upgrades for `/api/terminal` (Caddy does automatically;
|
|
396
414
|
nginx needs its usual HTTP/1.1 `Upgrade`/`Connection` forwarding).
|
|
397
415
|
|
package/package.json
CHANGED