@timqi/pier 0.0.5 → 0.0.7
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 +1 -0
- package/dist/agent/events.js +6 -1
- package/dist/agent/pi.js +28 -3
- package/dist/channels/chunk.js +34 -0
- package/dist/channels/control.js +6 -12
- package/dist/channels/dedup.js +45 -0
- package/dist/channels/lark-api.js +233 -0
- package/dist/channels/lark-outbound.js +101 -0
- package/dist/channels/lark-panel.js +95 -0
- package/dist/channels/lark-render.js +107 -0
- package/dist/channels/lark.js +501 -0
- package/dist/channels/lines.js +19 -0
- package/dist/channels/panel.js +4 -0
- package/dist/channels/receipts.js +15 -0
- package/dist/channels/routes.js +0 -1
- package/dist/channels/runtime.js +5 -1
- package/dist/channels/slack-api.js +4 -2
- package/dist/channels/slack-panel.js +3 -6
- package/dist/channels/slack-render.js +3 -23
- package/dist/channels/slack.js +39 -72
- package/dist/channels/telegram-api.js +4 -2
- package/dist/channels/telegram-panel.js +3 -3
- package/dist/channels/telegram.js +55 -51
- package/dist/channels/types.js +9 -0
- package/dist/core/identity.js +21 -0
- package/dist/core/inbox.js +67 -1
- package/dist/core/types.js +4 -0
- package/dist/db.js +6 -0
- package/dist/main.js +5 -0
- package/dist/web/auth.js +36 -9
- package/dist/web/public/assets/__vite-browser-external-2447137e-BvRk9kiK.js +0 -0
- package/dist/web/public/assets/ghostty-web-BhZV0Vvv.js +13 -0
- package/dist/web/public/assets/{index-BAW9Nhaa.js → index-BbwoGR-O.js} +17 -17
- package/dist/web/public/assets/index-BlHvP59B.css +2 -0
- package/dist/web/public/index.html +16 -6
- package/dist/web/server.js +86 -19
- package/dist/web/session-state.js +59 -25
- package/dist/web/terminal.js +334 -0
- package/docs/deploy.md +5 -1
- package/package.json +10 -2
- package/dist/web/public/assets/index-CwBoxtXP.css +0 -2
package/dist/core/inbox.js
CHANGED
|
@@ -10,7 +10,7 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|
|
10
10
|
import { randomBytes } from "node:crypto";
|
|
11
11
|
import { basename, join } from "node:path";
|
|
12
12
|
import { pierPath } from "../paths.js";
|
|
13
|
-
import { safeName } from "./inbound-file.js";
|
|
13
|
+
import { fileMarker, lostMarker, MAX_INBOUND_BYTES, safeName } from "./inbound-file.js";
|
|
14
14
|
/** Where every inbound file lives; web/files.ts allowlists this root. */
|
|
15
15
|
export const INBOX_DIR = pierPath("inbox");
|
|
16
16
|
/**
|
|
@@ -30,3 +30,69 @@ export async function saveInbound(channelId, name, mimeType, bytes) {
|
|
|
30
30
|
await writeFile(path, bytes, { mode: 0o600, flag: "wx" });
|
|
31
31
|
return path;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Collect a fetch response's body, refusing past `maxBytes` mid-stream. The
|
|
35
|
+
* metadata size gate in saveInboundAll is only as honest as the platform's
|
|
36
|
+
* metadata — absent or wrong, `arrayBuffer()` buffers whatever arrives — so
|
|
37
|
+
* the read itself is bounded too. Throws with "too large" in the message,
|
|
38
|
+
* which the loop below translates into the honest lost-marker reason.
|
|
39
|
+
*/
|
|
40
|
+
export async function readCapped(body, maxBytes) {
|
|
41
|
+
if (!body)
|
|
42
|
+
return new Uint8Array(0);
|
|
43
|
+
const parts = [];
|
|
44
|
+
let size = 0;
|
|
45
|
+
const reader = body.getReader();
|
|
46
|
+
try {
|
|
47
|
+
for (;;) {
|
|
48
|
+
const { done, value } = await reader.read();
|
|
49
|
+
if (done)
|
|
50
|
+
break;
|
|
51
|
+
size += value.byteLength;
|
|
52
|
+
if (size > maxBytes)
|
|
53
|
+
throw new Error(`attachment too large (>${maxBytes} bytes)`);
|
|
54
|
+
parts.push(value);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
// Also cancels the transfer on the too-large throw.
|
|
59
|
+
reader.releaseLock();
|
|
60
|
+
await body.cancel().catch(() => { });
|
|
61
|
+
}
|
|
62
|
+
const bytes = new Uint8Array(size);
|
|
63
|
+
let at = 0;
|
|
64
|
+
for (const part of parts) {
|
|
65
|
+
bytes.set(part, at);
|
|
66
|
+
at += part.byteLength;
|
|
67
|
+
}
|
|
68
|
+
return bytes;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Save a message's attachments; each becomes a marker line for the prompt —
|
|
72
|
+
* and a failed or oversized one becomes a lost-marker line, never silence
|
|
73
|
+
* (5b). Written three times, once per adapter, before landing here: the
|
|
74
|
+
* size gate before the fetch (an unauthorized sender is already filtered by
|
|
75
|
+
* then, but a movie must not be buffered whole either) and the never-silent
|
|
76
|
+
* failure path are invariants, and invariants drift when copied.
|
|
77
|
+
*/
|
|
78
|
+
export async function saveInboundAll(channelId, files, log) {
|
|
79
|
+
const markers = [];
|
|
80
|
+
for (const file of files) {
|
|
81
|
+
if (file.size !== undefined && file.size > MAX_INBOUND_BYTES) {
|
|
82
|
+
markers.push(lostMarker(file.label, "too large"));
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const got = await file.fetch();
|
|
87
|
+
const path = await saveInbound(channelId, file.name ?? got.name, got.mimeType ?? file.mimeType, got.bytes);
|
|
88
|
+
markers.push(fileMarker(path));
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
log(`attachment download failed: ${String(err)}`);
|
|
92
|
+
// A fetch that refused mid-stream names its reason; keep it honest.
|
|
93
|
+
const why = String(err).includes("too large") ? "too large" : "download failed";
|
|
94
|
+
markers.push(lostMarker(file.label, why));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return markers;
|
|
98
|
+
}
|
package/dist/core/types.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// Normative seam types — THIS FILE is the system contract (docs/architecture.md
|
|
2
2
|
// documents the rules around it). Changing a seam is a design decision, not a
|
|
3
3
|
// refactor; keep it implementable over RPC (no Pi types may appear here).
|
|
4
|
+
/** How much of a tool result any surface ever shows. A transcript replay
|
|
5
|
+
* carries no more than that: a session's tool output is most of its history
|
|
6
|
+
* payload, and the bytes past this point were downloaded to be sliced off. */
|
|
7
|
+
export const MAX_STEP_OUTPUT = 8_000;
|
|
4
8
|
/** Every level Pi accepts, in order. The union is derived so the two cannot
|
|
5
9
|
* drift, and boundary validators use isThinkingLevel instead of their own copy. */
|
|
6
10
|
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
package/dist/db.js
CHANGED
|
@@ -139,6 +139,12 @@ const MIGRATIONS = [
|
|
|
139
139
|
note TEXT NOT NULL,
|
|
140
140
|
created_at INTEGER NOT NULL
|
|
141
141
|
);
|
|
142
|
+
`,
|
|
143
|
+
// 4 — Projects can render from SQLite without scanning every Pi transcript.
|
|
144
|
+
`
|
|
145
|
+
ALTER TABLE session_state ADD COLUMN cwd TEXT;
|
|
146
|
+
ALTER TABLE session_state ADD COLUMN title TEXT;
|
|
147
|
+
ALTER TABLE session_state ADD COLUMN created_at INTEGER;
|
|
142
148
|
`,
|
|
143
149
|
];
|
|
144
150
|
let shared;
|
package/dist/main.js
CHANGED
|
@@ -34,6 +34,7 @@ import { startAutoUpdate, UpdateCheck } from "./update.js";
|
|
|
34
34
|
import { AuthStore, registerAuthRoutes, requireAuth } from "./web/auth.js";
|
|
35
35
|
import { SessionStateStore } from "./web/session-state.js";
|
|
36
36
|
import { createServer } from "./web/server.js";
|
|
37
|
+
import { attachTerminal } from "./web/terminal.js";
|
|
37
38
|
const log = logger("pier");
|
|
38
39
|
// Pier owns the Pi runtime dir. Set before any SDK call resolves a path, so
|
|
39
40
|
// everything Pi derives from its agent dir (auth.json, models.json, sessions,
|
|
@@ -280,6 +281,9 @@ const server = serve({ fetch: app.fetch, port, hostname }, () => {
|
|
|
280
281
|
log.info(`workbench on http://${hostname}:${port}`);
|
|
281
282
|
log.info(`pid ${process.pid}, node ${process.version}, home ${PIER_HOME}`);
|
|
282
283
|
});
|
|
284
|
+
// The one WebSocket surface (see web/terminal.ts); `serve` above builds a
|
|
285
|
+
// plain node:http server, which is the only shape with an upgrade event.
|
|
286
|
+
const terminals = attachTerminal(server, auth);
|
|
283
287
|
// A crash and a clean stop must be distinguishable after the fact, and both
|
|
284
288
|
// left nothing behind before this.
|
|
285
289
|
process.on("uncaughtException", (err) => {
|
|
@@ -302,6 +306,7 @@ const shutdown = (stopTasks = true) => {
|
|
|
302
306
|
// `systemctl restart` into a 90-second wait for SIGKILL.
|
|
303
307
|
setTimeout(() => process.exit(0), 3000).unref();
|
|
304
308
|
stopEviction();
|
|
309
|
+
terminals.close(); // no shell outlives the workbench
|
|
305
310
|
// The drain path leaves task runs alone: aborting them here would record
|
|
306
311
|
// them cancelled and race their callbacks against dying channels, when the
|
|
307
312
|
// boot-time interrupted marking is the recovery that was promised.
|
package/dist/web/auth.js
CHANGED
|
@@ -59,6 +59,7 @@ export class AuthStore {
|
|
|
59
59
|
#db;
|
|
60
60
|
/** HMAC key for cookies: the hash, so rotating the password expires them. */
|
|
61
61
|
#key;
|
|
62
|
+
#rotationListeners = new Set();
|
|
62
63
|
constructor(db = pierDb(), print = (m) => log.info(m)) {
|
|
63
64
|
this.#db = db;
|
|
64
65
|
let row = this.#row();
|
|
@@ -99,6 +100,13 @@ export class AuthStore {
|
|
|
99
100
|
.prepare("UPDATE auth SET salt = ?, hash = ?, created_at = ? WHERE id = 1")
|
|
100
101
|
.run(salt, next, Date.now());
|
|
101
102
|
this.#key = next;
|
|
103
|
+
for (const listener of this.#rotationListeners)
|
|
104
|
+
listener();
|
|
105
|
+
}
|
|
106
|
+
/** A long-lived authenticated surface closes itself when every cookie is
|
|
107
|
+
* revoked. The store and listeners share the process lifetime. */
|
|
108
|
+
onRotation(listener) {
|
|
109
|
+
this.#rotationListeners.add(listener);
|
|
102
110
|
}
|
|
103
111
|
/** Cookie signing key. Never the password: that is not stored anywhere. */
|
|
104
112
|
get cookieKey() {
|
|
@@ -181,19 +189,14 @@ function noteFailure(client) {
|
|
|
181
189
|
failures.set(client, { count: 1, resetAt: Date.now() + WINDOW_MS });
|
|
182
190
|
}
|
|
183
191
|
/** Browsers name the source of unsafe requests. Compare hosts rather than
|
|
184
|
-
* schemes because TLS commonly terminates at the reverse proxy.
|
|
185
|
-
|
|
186
|
-
|
|
192
|
+
* schemes because TLS commonly terminates at the reverse proxy. Shared by HTTP
|
|
193
|
+
* and WebSocket so the password boundary cannot disagree with itself. */
|
|
194
|
+
function originMatches(origin, host) {
|
|
187
195
|
if (!origin)
|
|
188
196
|
return true; // curl and other non-browser clients
|
|
189
197
|
try {
|
|
190
198
|
const parsed = new URL(origin);
|
|
191
|
-
const
|
|
192
|
-
const forwarded = remote && loopback(remote)
|
|
193
|
-
? c.req.header("x-forwarded-host")?.split(",").at(-1)?.trim()
|
|
194
|
-
: undefined;
|
|
195
|
-
const host = forwarded || c.req.header("host") || new URL(c.req.url).host;
|
|
196
|
-
const external = new URL(`${parsed.protocol}//${host}`);
|
|
199
|
+
const external = new URL(`${parsed.protocol}//${host ?? ""}`);
|
|
197
200
|
return parsed.origin === origin && external.origin === parsed.origin &&
|
|
198
201
|
external.pathname === "/" && !external.search && !external.hash;
|
|
199
202
|
}
|
|
@@ -201,6 +204,30 @@ function sameOrigin(c) {
|
|
|
201
204
|
return false;
|
|
202
205
|
}
|
|
203
206
|
}
|
|
207
|
+
function sameOrigin(c) {
|
|
208
|
+
const remote = remoteOf(c);
|
|
209
|
+
const forwarded = remote && loopback(remote)
|
|
210
|
+
? c.req.header("x-forwarded-host")?.split(",").at(-1)?.trim()
|
|
211
|
+
: undefined;
|
|
212
|
+
return originMatches(c.req.header("origin"), forwarded || c.req.header("host") || new URL(c.req.url).host);
|
|
213
|
+
}
|
|
214
|
+
/** The same cookie + Origin boundary for a WebSocket upgrade, where no Hono
|
|
215
|
+
* context exists before the handshake completes. */
|
|
216
|
+
export function upgradeAuthorized(store, req) {
|
|
217
|
+
const raw = req.headers.cookie
|
|
218
|
+
?.split(";")
|
|
219
|
+
.map((part) => part.trim())
|
|
220
|
+
.find((part) => part.startsWith(`${COOKIE}=`))
|
|
221
|
+
?.slice(COOKIE.length + 1);
|
|
222
|
+
if (!valid(store.cookieKey, raw))
|
|
223
|
+
return false;
|
|
224
|
+
const remote = req.socket.remoteAddress ?? "";
|
|
225
|
+
const forwardedHeader = req.headers["x-forwarded-host"];
|
|
226
|
+
const forwarded = loopback(remote) && typeof forwardedHeader === "string"
|
|
227
|
+
? forwardedHeader.split(",").at(-1)?.trim()
|
|
228
|
+
: undefined;
|
|
229
|
+
return originMatches(req.headers.origin, forwarded || req.headers.host);
|
|
230
|
+
}
|
|
204
231
|
/** Every route, in one place — no per-route opt-in to forget on the next one. */
|
|
205
232
|
export function requireAuth(store) {
|
|
206
233
|
return async (c, next) => {
|
|
File without changes
|