@timqi/pier 0.0.1 → 0.0.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/README.md +87 -12
- package/dist/agent/config.js +273 -27
- package/dist/agent/credentials.js +18 -12
- package/dist/agent/events.js +5 -41
- package/dist/agent/models.js +12 -0
- package/dist/agent/pi.js +182 -27
- package/dist/boards/boards.js +20 -10
- package/dist/channels/routes.js +1 -1
- package/dist/channels/runtime.js +36 -5
- package/dist/channels/slack-api.js +2 -4
- package/dist/channels/slack-outbound.js +4 -8
- package/dist/channels/slack-render.js +1 -4
- package/dist/channels/slack-tool.js +28 -3
- package/dist/channels/slack.js +20 -9
- package/dist/channels/telegram-api.js +3 -4
- package/dist/channels/telegram.js +37 -28
- package/dist/cli.js +177 -29
- package/dist/core/hub.js +36 -5
- package/dist/core/identity.js +5 -0
- package/dist/core/inbound-file.js +70 -0
- package/dist/core/inbox.js +32 -0
- package/dist/core/queue.js +9 -3
- package/dist/core/reply.js +20 -5
- package/dist/core/router.js +200 -14
- package/dist/core/types.js +53 -0
- package/dist/db.js +54 -8
- package/dist/drain.js +145 -0
- package/dist/main.js +180 -18
- package/dist/secrets.js +10 -6
- package/dist/service.js +192 -18
- package/dist/settings.js +77 -8
- package/dist/tasks/agent.js +41 -5
- package/dist/tasks/callbacks.js +29 -89
- package/dist/tasks/definitions.js +2 -6
- package/dist/tasks/execution.js +10 -1
- package/dist/tasks/groups.js +20 -49
- package/dist/tasks/messages.js +106 -21
- package/dist/tasks/outbox.js +157 -0
- package/dist/tasks/routes.js +6 -4
- package/dist/tasks/service.js +92 -22
- package/dist/tasks/store.js +48 -55
- package/dist/tasks/tool.js +19 -4
- package/dist/tasks/types.js +7 -0
- package/dist/update.js +146 -0
- package/dist/web/auth.js +89 -26
- package/dist/web/explorer.js +147 -0
- package/dist/web/files.js +28 -12
- package/dist/web/instance.js +165 -0
- package/dist/web/provider-flows.js +249 -0
- package/dist/web/providers.js +141 -0
- package/dist/web/public/assets/index-cCIuQnDr.css +2 -0
- package/dist/web/public/assets/index-fASxMPr6.js +90 -0
- package/dist/web/public/icon-192.png +0 -0
- package/dist/web/public/icon-32.png +0 -0
- package/dist/web/public/icon-512.png +0 -0
- package/dist/web/public/icon-maskable-512.png +0 -0
- package/dist/web/public/icon-touch-192.png +0 -0
- package/dist/web/public/icon.svg +29 -11
- package/dist/web/public/index.html +50 -32
- package/dist/web/server.js +110 -120
- package/docs/deploy.md +142 -64
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +16 -7
- package/skills/pier-help/SKILL.md +110 -0
- package/skills/pier-slack/SKILL.md +20 -3
- package/skills/pier-tasks/SKILL.md +19 -12
- package/dist/web/public/assets/index-8CinH1uR.css +0 -2
- package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
- package/dist/web/public/sw.js +0 -21
package/dist/web/auth.js
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
// whole revocation story a single-user system needs. A cookie (not a bearer
|
|
17
17
|
// header) because the workbench lives on SSE, and EventSource sends no headers.
|
|
18
18
|
import { createHash, createHmac, randomBytes, randomInt, scryptSync, timingSafeEqual, } from "node:crypto";
|
|
19
|
-
import {
|
|
19
|
+
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
20
|
+
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
20
21
|
import { pierDb } from "../db.js";
|
|
21
22
|
import { logger } from "../log.js";
|
|
22
23
|
const log = logger("auth");
|
|
@@ -25,6 +26,9 @@ const TTL_MS = 90 * 24 * 60 * 60_000;
|
|
|
25
26
|
/** Failed attempts one client may make before it has to wait out the window. */
|
|
26
27
|
const MAX_FAILURES = 10;
|
|
27
28
|
const WINDOW_MS = 15 * 60_000;
|
|
29
|
+
/** Distinct throttle buckets retained at once; the last is shared overflow. */
|
|
30
|
+
const MAX_FAILURE_CLIENTS = 1024;
|
|
31
|
+
const OVERFLOW_CLIENT = "\0overflow";
|
|
28
32
|
/** Shortest password a human may choose. The generated one is longer; this is
|
|
29
33
|
* the floor under which the throttle above stops being enough. */
|
|
30
34
|
const MIN_LENGTH = 10;
|
|
@@ -109,11 +113,12 @@ const hash = (password, salt) => scryptSync(password, salt, KEY_BYTES).toString(
|
|
|
109
113
|
* it. `/boards/*` stays behind the boundary; `/p/*` is the published mirror,
|
|
110
114
|
* the single exempt prefix `docs/architecture.md` reserved for this.
|
|
111
115
|
*/
|
|
112
|
-
function isPublic(path) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
116
|
+
function isPublic(method, path) {
|
|
117
|
+
if (path === "/login")
|
|
118
|
+
return method === "GET" || method === "HEAD" || method === "POST";
|
|
119
|
+
if (method !== "GET" && method !== "HEAD")
|
|
120
|
+
return false;
|
|
121
|
+
return path.startsWith("/p/") || path === "/boards/_assets/pier.css";
|
|
117
122
|
}
|
|
118
123
|
const sign = (secret, expiresAt) => createHmac("sha256", secret).update(String(expiresAt)).digest("base64url");
|
|
119
124
|
/** Constant-time equality that also hides length: both sides are digested. */
|
|
@@ -137,40 +142,90 @@ function valid(secret, cookie) {
|
|
|
137
142
|
const safeNext = (raw) => typeof raw === "string" && /^\/(?![/\\])/.test(raw) ? raw : "/";
|
|
138
143
|
// Failed logins per client, in memory: a restart clearing them is fine, since
|
|
139
144
|
// the window is minutes and the point is to make guessing slow, not to keep
|
|
140
|
-
// books.
|
|
145
|
+
// books. Expired entries are pruned, and fresh identities spill into one
|
|
146
|
+
// overflow bucket once the fixed map cap is reached.
|
|
141
147
|
const failures = new Map();
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
148
|
+
const loopback = (address) => address === "::1" || address.startsWith("127.") || address.startsWith("::ffff:127.");
|
|
149
|
+
function remoteOf(c) {
|
|
150
|
+
const env = c.env;
|
|
151
|
+
return env?.incoming || env?.server?.incoming
|
|
152
|
+
? getConnInfo(c).remote.address
|
|
153
|
+
: undefined;
|
|
154
|
+
}
|
|
155
|
+
/** Trust a forwarded address only from a local reverse proxy. The rightmost
|
|
156
|
+
* hop is the address that proxy appended, not one the client put at the front. */
|
|
149
157
|
function clientOf(c) {
|
|
150
|
-
|
|
158
|
+
const remote = remoteOf(c);
|
|
159
|
+
if (remote && !loopback(remote))
|
|
160
|
+
return remote;
|
|
161
|
+
return c.req.header("x-forwarded-for")?.split(",").at(-1)?.trim() || remote || "local";
|
|
162
|
+
}
|
|
163
|
+
function failureClient(client) {
|
|
164
|
+
if (failures.has(client) || failures.size < MAX_FAILURE_CLIENTS - 1)
|
|
165
|
+
return client;
|
|
166
|
+
return OVERFLOW_CLIENT;
|
|
151
167
|
}
|
|
152
168
|
function throttled(client) {
|
|
153
169
|
const now = Date.now();
|
|
154
170
|
for (const [id, entry] of failures)
|
|
155
171
|
if (entry.resetAt <= now)
|
|
156
172
|
failures.delete(id);
|
|
157
|
-
return (failures.get(client)?.count ?? 0) >= MAX_FAILURES;
|
|
173
|
+
return (failures.get(failureClient(client))?.count ?? 0) >= MAX_FAILURES;
|
|
158
174
|
}
|
|
159
175
|
function noteFailure(client) {
|
|
176
|
+
client = failureClient(client);
|
|
160
177
|
const entry = failures.get(client);
|
|
161
178
|
if (entry && entry.resetAt > Date.now())
|
|
162
179
|
entry.count += 1;
|
|
163
180
|
else
|
|
164
181
|
failures.set(client, { count: 1, resetAt: Date.now() + WINDOW_MS });
|
|
165
182
|
}
|
|
183
|
+
/** Browsers name the source of unsafe requests. Compare hosts rather than
|
|
184
|
+
* schemes because TLS commonly terminates at the reverse proxy. */
|
|
185
|
+
function sameOrigin(c) {
|
|
186
|
+
const origin = c.req.header("origin");
|
|
187
|
+
if (!origin)
|
|
188
|
+
return true; // curl and other non-browser clients
|
|
189
|
+
try {
|
|
190
|
+
const parsed = new URL(origin);
|
|
191
|
+
const remote = remoteOf(c);
|
|
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}`);
|
|
197
|
+
return parsed.origin === origin && external.origin === parsed.origin &&
|
|
198
|
+
external.pathname === "/" && !external.search && !external.hash;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
166
204
|
/** Every route, in one place — no per-route opt-in to forget on the next one. */
|
|
167
205
|
export function requireAuth(store) {
|
|
168
206
|
return async (c, next) => {
|
|
169
|
-
|
|
207
|
+
// On every response, public ones included: the login form is the one page
|
|
208
|
+
// strangers reach, and it must not be frameable either.
|
|
209
|
+
c.header("x-frame-options", "DENY");
|
|
210
|
+
if (isPublic(c.req.method, c.req.path))
|
|
170
211
|
return next();
|
|
212
|
+
const authenticated = valid(store.cookieKey, getCookie(c, COOKIE));
|
|
213
|
+
const unsafe = c.req.method !== "GET" && c.req.method !== "HEAD";
|
|
214
|
+
if (authenticated && unsafe && !sameOrigin(c)) {
|
|
215
|
+
log.warn(`blocked ${c.req.method} ${c.req.path} from origin ${c.req.header("origin")}`);
|
|
216
|
+
return c.json({ error: "forbidden origin" }, 403);
|
|
217
|
+
}
|
|
218
|
+
if (authenticated) {
|
|
219
|
+
await next();
|
|
220
|
+
// Cookie-authenticated content must not become public in a shared proxy.
|
|
221
|
+
if (!c.res.headers.has("cache-control")) {
|
|
222
|
+
c.header("cache-control", c.req.path.startsWith("/api/") ? "private, no-store" : "private");
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
171
226
|
// An API caller gets a status it can act on; a navigation gets the form.
|
|
172
227
|
// Anything non-GET is a client call too — never a link worth redirecting.
|
|
173
|
-
if (c.req.path.startsWith("/api/") ||
|
|
228
|
+
if (c.req.path.startsWith("/api/") || unsafe) {
|
|
174
229
|
return c.json({ error: "unauthorized" }, 401);
|
|
175
230
|
}
|
|
176
231
|
return c.redirect(`/login?next=${encodeURIComponent(c.req.path)}`);
|
|
@@ -198,10 +253,8 @@ export function registerAuthRoutes(app, store) {
|
|
|
198
253
|
issueCookie(c, store);
|
|
199
254
|
return c.redirect(next);
|
|
200
255
|
});
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
// "sign in" does. So it needs no cookie of its own: whoever knows the current
|
|
204
|
-
// password can already get one.
|
|
256
|
+
// Re-authenticate before rotating the credential. The global boundary also
|
|
257
|
+
// requires a live cookie; knowing a password is not permission to call APIs.
|
|
205
258
|
app.post("/api/password", async (c) => {
|
|
206
259
|
const client = clientOf(c);
|
|
207
260
|
const body = (await c.req.json().catch(() => null));
|
|
@@ -211,16 +264,26 @@ export function registerAuthRoutes(app, store) {
|
|
|
211
264
|
return c.json({ error: "Too many attempts. Wait a few minutes." }, 429);
|
|
212
265
|
if (!store.verify(current)) {
|
|
213
266
|
noteFailure(client);
|
|
214
|
-
return c.json({ error: "Wrong current password." },
|
|
267
|
+
return c.json({ error: "Wrong current password." }, 403);
|
|
215
268
|
}
|
|
216
269
|
if (next.length < MIN_LENGTH) {
|
|
217
270
|
return c.json({ error: `Use at least ${MIN_LENGTH} characters.` }, 400);
|
|
218
271
|
}
|
|
219
272
|
failures.delete(client);
|
|
220
273
|
store.setPassword(next);
|
|
221
|
-
// The rotation just killed this caller's
|
|
222
|
-
//
|
|
223
|
-
|
|
274
|
+
// The rotation just killed every cookie out there, this caller's included —
|
|
275
|
+
// a password is changed because the old one may be known, and "everyone
|
|
276
|
+
// signs in again" is the whole point. Clear the dead cookie; the client
|
|
277
|
+
// sends the person to the login form with the password they just chose.
|
|
278
|
+
deleteCookie(c, COOKIE, { path: "/" });
|
|
279
|
+
return c.json({ ok: true });
|
|
280
|
+
});
|
|
281
|
+
// Signs out this browser by clearing its cookie. The value itself stays
|
|
282
|
+
// verifiable until it expires — it is a signature, not a stored id — so the
|
|
283
|
+
// full revocation story remains the password change above. Behind the
|
|
284
|
+
// boundary like every write: only a signed-in browser has anything to end.
|
|
285
|
+
app.post("/logout", (c) => {
|
|
286
|
+
deleteCookie(c, COOKIE, { path: "/" });
|
|
224
287
|
return c.json({ ok: true });
|
|
225
288
|
});
|
|
226
289
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Files view backend: read-only directory listing, file bytes and git
|
|
2
|
+
// ref/diff queries for the Console's Files view. `root` is any directory the
|
|
3
|
+
// process can read — sessions work in worktrees and siblings of their cwd, and
|
|
4
|
+
// an owner who is already past the Console password can reach those paths
|
|
5
|
+
// anyway. `path` is still confined to the `root` it was asked under, so a
|
|
6
|
+
// listing can never widen itself, and nothing here writes.
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
9
|
+
import { basename, extname, isAbsolute, resolve, sep } from "node:path";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
import { guarded } from "./files.js";
|
|
12
|
+
const run = promisify(execFile);
|
|
13
|
+
// Inline-renderable binary types; text is sniffed, everything else downloads.
|
|
14
|
+
const BINARY_TYPES = {
|
|
15
|
+
".png": "image/png",
|
|
16
|
+
".jpg": "image/jpeg",
|
|
17
|
+
".jpeg": "image/jpeg",
|
|
18
|
+
".gif": "image/gif",
|
|
19
|
+
".webp": "image/webp",
|
|
20
|
+
".avif": "image/avif",
|
|
21
|
+
".bmp": "image/bmp",
|
|
22
|
+
".svg": "image/svg+xml",
|
|
23
|
+
".pdf": "application/pdf",
|
|
24
|
+
};
|
|
25
|
+
const MAX_FILE_BYTES = 32 * 1024 * 1024;
|
|
26
|
+
const MAX_DIFF_BYTES = 2 * 1024 * 1024;
|
|
27
|
+
/** A ref never starts with `-`: execFile blocks the shell, this blocks the
|
|
28
|
+
* argument parser (`--output=…` is a write). Git validates the rest. */
|
|
29
|
+
const REF_RE = /^[^-\s][^\s]*$/;
|
|
30
|
+
/** git in `root`, output capped — a diff is display data, not an archive. */
|
|
31
|
+
const git = async (root, ...args) => (await run("git", ["-C", root, ...args], { maxBuffer: MAX_DIFF_BYTES })).stdout;
|
|
32
|
+
export function registerExplorerRoutes(app) {
|
|
33
|
+
/** The scope check every route shares: `root` must be an absolute directory,
|
|
34
|
+
* and `path` must resolve inside it (realpath both ends — neither `..` nor a
|
|
35
|
+
* symlink steps outside). Returns the real target. */
|
|
36
|
+
const resolveScoped = async (root, path = "") => {
|
|
37
|
+
if (!root || !isAbsolute(root))
|
|
38
|
+
throw new Error("root must be an absolute directory");
|
|
39
|
+
const real = await realpath(root);
|
|
40
|
+
if (!(await stat(real)).isDirectory())
|
|
41
|
+
throw new Error("root must be an absolute directory");
|
|
42
|
+
const target = await realpath(resolve(real, path));
|
|
43
|
+
if (target !== real && !target.startsWith(real + sep))
|
|
44
|
+
throw new Error("path escapes root");
|
|
45
|
+
return target;
|
|
46
|
+
};
|
|
47
|
+
// Directory listing, names only. `.git` is plumbing, not content.
|
|
48
|
+
guarded(app, "GET", "/api/explorer/ls", 404, async (c) => {
|
|
49
|
+
c.header("cache-control", "no-store");
|
|
50
|
+
const dir = await resolveScoped(c.req.query("root"), c.req.query("path"));
|
|
51
|
+
const entries = (await readdir(dir, { withFileTypes: true }))
|
|
52
|
+
.filter((e) => e.name !== ".git" && (e.isDirectory() || e.isFile()))
|
|
53
|
+
.map((e) => ({ name: e.name, dir: e.isDirectory() }))
|
|
54
|
+
.sort((a, b) => Number(b.dir) - Number(a.dir) || a.name.localeCompare(b.name));
|
|
55
|
+
return c.json({ entries });
|
|
56
|
+
});
|
|
57
|
+
// File bytes, read-only. Known binary types render inline; anything else is
|
|
58
|
+
// sniffed — a NUL in the head means bytes we can't vouch for, so it
|
|
59
|
+
// downloads instead of rendering (that is how a file starts executing).
|
|
60
|
+
guarded(app, "GET", "/api/explorer/file", 404, async (c) => {
|
|
61
|
+
const file = await resolveScoped(c.req.query("root"), c.req.query("path"));
|
|
62
|
+
if (!(await stat(file)).isFile())
|
|
63
|
+
throw new Error("not a file");
|
|
64
|
+
const bytes = await readFile(file);
|
|
65
|
+
if (bytes.byteLength > MAX_FILE_BYTES)
|
|
66
|
+
return c.json({ error: "file too large" }, 413);
|
|
67
|
+
const binary = BINARY_TYPES[extname(file).toLowerCase()];
|
|
68
|
+
const text = !binary && !bytes.subarray(0, 8192).includes(0);
|
|
69
|
+
return c.body(bytes, 200, {
|
|
70
|
+
"content-type": binary ?? (text ? "text/plain; charset=utf-8" : "application/octet-stream"),
|
|
71
|
+
"content-disposition": `${binary || text ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(basename(file))}`,
|
|
72
|
+
"cache-control": "no-store",
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
// Git refs for the diff pickers: current branch, branches+tags, recent
|
|
76
|
+
// commits. Not a repo → { branch: null }, which the UI renders as "no git".
|
|
77
|
+
guarded(app, "GET", "/api/explorer/git", 404, async (c) => {
|
|
78
|
+
c.header("cache-control", "no-store");
|
|
79
|
+
const root = await resolveScoped(c.req.query("root"));
|
|
80
|
+
let branch;
|
|
81
|
+
try {
|
|
82
|
+
branch = (await git(root, "rev-parse", "--abbrev-ref", "HEAD")).trim();
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return c.json({ branch: null, refs: [], commits: [] }); // not a repo, or no commits yet
|
|
86
|
+
}
|
|
87
|
+
const refs = (await git(root, "for-each-ref", "--format=%(refname:short)\t%(subject)", "refs/heads", "refs/tags"))
|
|
88
|
+
.split("\n")
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.map((line) => {
|
|
91
|
+
const tab = line.indexOf("\t");
|
|
92
|
+
return { name: line.slice(0, tab), subject: line.slice(tab + 1) };
|
|
93
|
+
});
|
|
94
|
+
// Unit/record separators, because a body is multi-line by nature.
|
|
95
|
+
const commits = (await git(root, "log", "-20", "--format=%h\u001f%at\u001f%an\u001f%s\u001f%b\u001e"))
|
|
96
|
+
.split("\u001e")
|
|
97
|
+
.map((r) => r.trimStart())
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.map((r) => {
|
|
100
|
+
const [hash = "", at = "", author = "", subject = "", body = ""] = r.split("\u001f");
|
|
101
|
+
return { hash, at: Number(at) * 1000, author, subject, body: body.trim() };
|
|
102
|
+
});
|
|
103
|
+
return c.json({ branch, refs, commits });
|
|
104
|
+
});
|
|
105
|
+
// One endpoint, two shapes: without `file` the changed-file list
|
|
106
|
+
// (name-status), with it that file's unified diff. `head` empty or absent
|
|
107
|
+
// means the working tree.
|
|
108
|
+
guarded(app, "GET", "/api/explorer/diff", 404, async (c) => {
|
|
109
|
+
c.header("cache-control", "no-store");
|
|
110
|
+
const root = await resolveScoped(c.req.query("root"));
|
|
111
|
+
const base = c.req.query("base") ?? "";
|
|
112
|
+
const head = c.req.query("head") ?? "";
|
|
113
|
+
if (!REF_RE.test(base) || (head !== "" && !REF_RE.test(head)))
|
|
114
|
+
return c.json({ error: "invalid ref" }, 400);
|
|
115
|
+
const range = head ? [base, head] : [base];
|
|
116
|
+
// Context radius for per-file diffs — the UI asks for a huge one to render
|
|
117
|
+
// the whole file with changes toned inline, not a bare patch.
|
|
118
|
+
const context = Math.min(99_999, Math.max(0, Math.trunc(Number(c.req.query("context"))) || 0));
|
|
119
|
+
const file = c.req.query("file");
|
|
120
|
+
if (file === undefined) {
|
|
121
|
+
// name-status carries the letter, numstat the +/- counts; joined by path.
|
|
122
|
+
const [nameStatus, numstat] = await Promise.all([
|
|
123
|
+
git(root, "diff", "--name-status", ...range, "--"),
|
|
124
|
+
git(root, "diff", "--numstat", ...range, "--"),
|
|
125
|
+
]);
|
|
126
|
+
// numstat spells a rename "a/{old => new}.ts" — reduce it to the new path.
|
|
127
|
+
const newPath = (p) => p.replace(/\{([^{}]*) => ([^{}]*)\}/g, "$2").replace(/^(.*) => (.*)$/, "$2");
|
|
128
|
+
const counts = new Map(numstat.split("\n").filter(Boolean).map((line) => {
|
|
129
|
+
const [add = "", del = "", ...path] = line.split("\t");
|
|
130
|
+
// "-" on both sides means binary — no line counts to report.
|
|
131
|
+
return [newPath(path.join("\t")), { add: Number(add) || 0, del: Number(del) || 0 }];
|
|
132
|
+
}));
|
|
133
|
+
const files = nameStatus
|
|
134
|
+
.split("\n")
|
|
135
|
+
.filter(Boolean)
|
|
136
|
+
.map((line) => {
|
|
137
|
+
// Renames/copies are "R100\told\tnew" — show the new path.
|
|
138
|
+
const [status = "", ...paths] = line.split("\t");
|
|
139
|
+
const path = paths[paths.length - 1] ?? "";
|
|
140
|
+
return { status: status.charAt(0), path, ...(counts.get(path) ?? { add: 0, del: 0 }) };
|
|
141
|
+
});
|
|
142
|
+
return c.json({ files });
|
|
143
|
+
}
|
|
144
|
+
// The path only reaches git behind `--`, so it is data, never an option.
|
|
145
|
+
return c.json({ diff: await git(root, "diff", `-U${context || 3}`, ...range, "--", file) });
|
|
146
|
+
});
|
|
147
|
+
}
|
package/dist/web/files.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { mkdir, readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { basename, dirname, extname, isAbsolute, resolve, sep } from "node:path";
|
|
8
|
+
import { INBOX_DIR } from "../core/inbox.js";
|
|
8
9
|
// Content types for the attachment route. Anything unlisted downloads as
|
|
9
10
|
// bytes — guessing a type we can't vouch for is how a file starts executing.
|
|
10
11
|
const FILE_TYPES = {
|
|
@@ -38,7 +39,7 @@ export function guarded(app, method, path, status, fn) {
|
|
|
38
39
|
}
|
|
39
40
|
});
|
|
40
41
|
}
|
|
41
|
-
export function registerFileRoutes(app, { factory, config, nascentCwd }) {
|
|
42
|
+
export function registerFileRoutes(app, { factory, config, nascentCwd, onConfigWritten }) {
|
|
42
43
|
// Scope comes from the client as "global" or a project cwd; only cwds Pi
|
|
43
44
|
// already knows (the session list) are accepted — never an arbitrary path.
|
|
44
45
|
const parseScope = async (raw) => {
|
|
@@ -48,15 +49,19 @@ export function registerFileRoutes(app, { factory, config, nascentCwd }) {
|
|
|
48
49
|
return known.some((s) => s.cwd === raw) ? { kind: "project", cwd: raw } : null;
|
|
49
50
|
};
|
|
50
51
|
app.get("/api/config", async (c) => {
|
|
52
|
+
c.header("cache-control", "no-store");
|
|
51
53
|
const scope = await parseScope(c.req.query("scope"));
|
|
52
54
|
if (!scope)
|
|
53
55
|
return c.json({ error: "unknown scope" }, 400);
|
|
54
56
|
return c.json({
|
|
57
|
+
// Where this scope's files live on disk — the UI labels "Global" with it.
|
|
58
|
+
dir: scope.kind === "global" ? config.globalDir : scope.cwd,
|
|
55
59
|
files: await config.listFiles(scope),
|
|
56
60
|
resources: await config.listResources(scope),
|
|
57
61
|
});
|
|
58
62
|
});
|
|
59
63
|
guarded(app, "GET", "/api/config/files/:name", 400, async (c) => {
|
|
64
|
+
c.header("cache-control", "no-store");
|
|
60
65
|
const scope = await parseScope(c.req.query("scope"));
|
|
61
66
|
if (!scope)
|
|
62
67
|
return c.json({ error: "unknown scope" }, 400);
|
|
@@ -67,13 +72,17 @@ export function registerFileRoutes(app, { factory, config, nascentCwd }) {
|
|
|
67
72
|
if (!scope)
|
|
68
73
|
return c.json({ error: "unknown scope" }, 400);
|
|
69
74
|
const body = await c.req.json().catch(() => null);
|
|
70
|
-
if (typeof body?.content !== "string")
|
|
71
|
-
return c.json({ error: "content required" }, 400);
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
if (typeof body?.content !== "string" || typeof body?.expected !== "string") {
|
|
76
|
+
return c.json({ error: "content and expected content required" }, 400);
|
|
77
|
+
}
|
|
78
|
+
const name = c.req.param("name");
|
|
79
|
+
await config.writeFile(scope, name, body.content, body.expected);
|
|
80
|
+
onConfigWritten?.();
|
|
81
|
+
return c.json({ ok: true, content: await config.readFile(scope, name) });
|
|
74
82
|
});
|
|
75
83
|
// Resource names may contain slashes — query params, not path params.
|
|
76
84
|
guarded(app, "GET", "/api/config/resource", 400, async (c) => {
|
|
85
|
+
c.header("cache-control", "no-store");
|
|
77
86
|
const scope = await parseScope(c.req.query("scope"));
|
|
78
87
|
if (!scope)
|
|
79
88
|
return c.json({ error: "unknown scope" }, 400);
|
|
@@ -84,16 +93,23 @@ export function registerFileRoutes(app, { factory, config, nascentCwd }) {
|
|
|
84
93
|
}
|
|
85
94
|
return c.json({ content: await config.readResource(scope, kind, name) });
|
|
86
95
|
});
|
|
87
|
-
/** Real path of a file a session may expose: inside its cwd
|
|
96
|
+
/** Real path of a file a session may expose: inside its cwd or the inbox
|
|
97
|
+
* (where inbound user attachments land — core/inbox.ts), nothing else. */
|
|
88
98
|
const resolveFile = async (id, raw) => {
|
|
89
99
|
const cwd = nascentCwd(id) ?? (await factory.list()).find((s) => s.id === id)?.cwd;
|
|
90
100
|
if (!cwd || !isAbsolute(raw))
|
|
91
101
|
return null;
|
|
92
102
|
try {
|
|
93
103
|
// realpath both ends, so neither `..` nor a symlink can step outside.
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
104
|
+
const roots = [await realpath(cwd)];
|
|
105
|
+
try {
|
|
106
|
+
roots.push(await realpath(INBOX_DIR));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
/* no inbox yet — nothing was ever uploaded */
|
|
110
|
+
}
|
|
111
|
+
const target = await realpath(resolve(roots[0], raw));
|
|
112
|
+
if (!roots.some((root) => target === root || target.startsWith(root + sep)))
|
|
97
113
|
return null;
|
|
98
114
|
return (await stat(target)).isFile() ? target : null;
|
|
99
115
|
}
|
|
@@ -101,9 +117,9 @@ export function registerFileRoutes(app, { factory, config, nascentCwd }) {
|
|
|
101
117
|
return null; // missing, unreadable, or not a file
|
|
102
118
|
}
|
|
103
119
|
};
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
// the session's own
|
|
120
|
+
// Attachments, both directions: the agent links a file it produced, the
|
|
121
|
+
// chat renders a file the user sent — the client fetches the bytes here,
|
|
122
|
+
// read-only, and only from the session's own cwd or the inbox.
|
|
107
123
|
app.get("/api/sessions/:id/files", async (c) => {
|
|
108
124
|
const raw = c.req.query("path");
|
|
109
125
|
if (!raw)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Routes about the Pier instance itself — settings, update availability,
|
|
2
|
+
// layer-1 secrets control, the browser's error reports. Nothing here touches
|
|
3
|
+
// a session; server.ts stays the session/event surface.
|
|
4
|
+
import { logger } from "../log.js";
|
|
5
|
+
import { normalizeModelMenu, normalizePublicUrl } from "../settings.js";
|
|
6
|
+
/** Client reports per minute, for the whole server: a browser bug can fire in
|
|
7
|
+
* a loop, and the journal is shared with everything else Pier says. */
|
|
8
|
+
const CLIENT_LOG_PER_MINUTE = 60;
|
|
9
|
+
export function registerInstanceRoutes(app, deps) {
|
|
10
|
+
const { settings, updates, updater = null, secrets, onUnlocked, onSettingsChanged } = deps;
|
|
11
|
+
const updateLog = logger("update");
|
|
12
|
+
// How long POST /api/update may hold its response open. A busy Pier drains
|
|
13
|
+
// first, which can take minutes, and a response held that long dies at every
|
|
14
|
+
// proxy on the way (principle 7): past this cap the answer is "draining".
|
|
15
|
+
const APPLY_REPLY_CAP_MS = 10_000;
|
|
16
|
+
// The browser's half of the log. A workbench that threw after the response
|
|
17
|
+
// left the server is otherwise invisible here (ui/report.ts) — this is the
|
|
18
|
+
// one route whose entire purpose is to make it visible.
|
|
19
|
+
const clientLog = logger("client");
|
|
20
|
+
let reports = [];
|
|
21
|
+
app.post("/api/client-log", async (c) => {
|
|
22
|
+
const body = (await c.req.json().catch(() => null));
|
|
23
|
+
if (typeof body?.message !== "string" || !body.message.trim()) {
|
|
24
|
+
return c.json({ error: "message required" }, 400);
|
|
25
|
+
}
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
reports = reports.filter((at) => now - at < 60_000);
|
|
28
|
+
if (reports.length >= CLIENT_LOG_PER_MINUTE)
|
|
29
|
+
return c.body(null, 429);
|
|
30
|
+
reports.push(now);
|
|
31
|
+
const cap = (value, max) => typeof value === "string" ? value.slice(0, max) : "";
|
|
32
|
+
const where = cap(body.view, 120);
|
|
33
|
+
const stack = cap(body.stack, 2000);
|
|
34
|
+
// One line, ua included: "only on iOS" is the answer half these questions
|
|
35
|
+
// have, and the report is the only place it exists.
|
|
36
|
+
clientLog.warn(`${cap(body.message, 500)} [${where || "/"}] ${cap(c.req.header("user-agent"), 160)}` +
|
|
37
|
+
(stack ? `\n${stack}` : ""));
|
|
38
|
+
return c.body(null, 204);
|
|
39
|
+
});
|
|
40
|
+
// Instance settings. The password lives behind its own route (web/auth.ts):
|
|
41
|
+
// it is a credential, and changing it takes the old one.
|
|
42
|
+
app.get("/api/settings", (c) => c.json(settings.get()));
|
|
43
|
+
// What the version badge reads: the two versions, whether this instance can
|
|
44
|
+
// do anything about the gap, and whether it is allowed to do it unattended.
|
|
45
|
+
// `statusNow` so a browser opened seconds after a restart is told the truth
|
|
46
|
+
// rather than "no idea yet".
|
|
47
|
+
app.get("/api/update", async (c) => c.json({
|
|
48
|
+
...(await updates.statusNow()),
|
|
49
|
+
canApply: updater !== null,
|
|
50
|
+
autoUpdate: settings.get().autoUpdate,
|
|
51
|
+
// Reported whether or not an update is pending: the repair is the same,
|
|
52
|
+
// and finding out at the next restart is finding out too late.
|
|
53
|
+
problem: updater?.problem() ?? null,
|
|
54
|
+
}));
|
|
55
|
+
// Applying. Nothing is installed here: the work is handed to the service
|
|
56
|
+
// manager's own oneshot unit, which stops Pier, backs the database up,
|
|
57
|
+
// installs and starts Pier again — an npm child of this process would be
|
|
58
|
+
// killed by the very restart it is performing.
|
|
59
|
+
app.post("/api/update", async (c) => {
|
|
60
|
+
if (!updater) {
|
|
61
|
+
return c.json({ error: "no service manager owns this Pier — update it with: pier update" }, 409);
|
|
62
|
+
}
|
|
63
|
+
const { current, latest, available } = await updates.statusNow();
|
|
64
|
+
if (!available) {
|
|
65
|
+
return c.json({ error: latest === null ? "the registry could not be reached" : `${current} is the latest` }, 409);
|
|
66
|
+
}
|
|
67
|
+
const problem = updater.problem();
|
|
68
|
+
if (problem !== null) {
|
|
69
|
+
updateLog.error(`update to ${latest} refused: ${problem}`);
|
|
70
|
+
return c.json({ error: problem }, 409);
|
|
71
|
+
}
|
|
72
|
+
const applied = updater.apply().catch((err) => {
|
|
73
|
+
updateLog.error("update handover failed", err);
|
|
74
|
+
return "failed";
|
|
75
|
+
});
|
|
76
|
+
const started = await Promise.race([
|
|
77
|
+
applied,
|
|
78
|
+
new Promise((resolve) => setTimeout(resolve, APPLY_REPLY_CAP_MS, "draining").unref()),
|
|
79
|
+
]);
|
|
80
|
+
if (started === "draining") {
|
|
81
|
+
// The handover keeps running behind this response; if it fails later,
|
|
82
|
+
// main.ts's takeWorkAgain reports it and reopens the gate (§5b).
|
|
83
|
+
updateLog.info(`updating to ${latest} on the Console's request — waiting for running work to finish`);
|
|
84
|
+
return c.json({ started: true, draining: true, latest }, 202);
|
|
85
|
+
}
|
|
86
|
+
if (started === "busy") {
|
|
87
|
+
return c.json({ error: "an update or restart is already in progress" }, 409);
|
|
88
|
+
}
|
|
89
|
+
if (started !== "started") {
|
|
90
|
+
updateLog.error(`update to ${latest} refused by the updater: ${started}`);
|
|
91
|
+
return c.json({
|
|
92
|
+
error: started === "not-installed"
|
|
93
|
+
? "the systemd unit is not installed — run: pier service install"
|
|
94
|
+
: "the updater could not be started; see the journal",
|
|
95
|
+
}, 500);
|
|
96
|
+
}
|
|
97
|
+
updateLog.info(`updating to ${latest} on the Console's request — Pier stops and starts again`);
|
|
98
|
+
return c.json({ started: true, latest });
|
|
99
|
+
});
|
|
100
|
+
// Partial on purpose: each surface sends only the setting it edits, and a
|
|
101
|
+
// malformed field is rejected before anything is written.
|
|
102
|
+
app.put("/api/settings", async (c) => {
|
|
103
|
+
const body = await c.req.json().catch(() => null);
|
|
104
|
+
if (!body || (body.publicUrl === undefined && body.modelMenu === undefined && body.autoUpdate === undefined)) {
|
|
105
|
+
return c.json({ error: "publicUrl, modelMenu or autoUpdate required" }, 400);
|
|
106
|
+
}
|
|
107
|
+
if (body.publicUrl !== undefined) {
|
|
108
|
+
if (typeof body.publicUrl !== "string")
|
|
109
|
+
return c.json({ error: "publicUrl must be a string" }, 400);
|
|
110
|
+
const publicUrl = normalizePublicUrl(body.publicUrl);
|
|
111
|
+
if (publicUrl === null) {
|
|
112
|
+
return c.json({ error: "not a URL: expected http(s)://host, no query or fragment" }, 400);
|
|
113
|
+
}
|
|
114
|
+
settings.setPublicUrl(publicUrl);
|
|
115
|
+
}
|
|
116
|
+
if (body.modelMenu !== undefined) {
|
|
117
|
+
const menu = normalizeModelMenu(body.modelMenu);
|
|
118
|
+
if (menu === null) {
|
|
119
|
+
return c.json({ error: "modelMenu must be [{provider, id, note?}] (≤32 entries)" }, 400);
|
|
120
|
+
}
|
|
121
|
+
settings.setModelMenu(menu);
|
|
122
|
+
}
|
|
123
|
+
if (body.autoUpdate !== undefined) {
|
|
124
|
+
if (typeof body.autoUpdate !== "boolean")
|
|
125
|
+
return c.json({ error: "autoUpdate must be a boolean" }, 400);
|
|
126
|
+
settings.setAutoUpdate(body.autoUpdate);
|
|
127
|
+
}
|
|
128
|
+
// Only the URL: the model menu is read per picker call, not per session.
|
|
129
|
+
if (body.publicUrl !== undefined)
|
|
130
|
+
onSettingsChanged?.();
|
|
131
|
+
return c.json(settings.get());
|
|
132
|
+
});
|
|
133
|
+
// Layer-1 key status and control (Console → Settings → Security). The GET
|
|
134
|
+
// is what a locked instance shows; unlock is how it recovers without a
|
|
135
|
+
// restart, and rotate is the only way to change how the KEK is protected.
|
|
136
|
+
const secretsStatus = () => ({
|
|
137
|
+
state: secrets.state,
|
|
138
|
+
mode: secrets.mode ?? null,
|
|
139
|
+
...(secrets.state === "locked" ? { reason: secrets.lockedReason } : {}),
|
|
140
|
+
});
|
|
141
|
+
app.get("/api/secrets", (c) => c.json(secretsStatus()));
|
|
142
|
+
app.post("/api/secrets/unlock", async (c) => {
|
|
143
|
+
try {
|
|
144
|
+
await secrets.unlock();
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
return c.json({ error: String(err) }, 500);
|
|
148
|
+
}
|
|
149
|
+
onUnlocked?.();
|
|
150
|
+
return c.json(secretsStatus());
|
|
151
|
+
});
|
|
152
|
+
app.post("/api/secrets/rotate", async (c) => {
|
|
153
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
154
|
+
if (body.mode !== undefined && body.mode !== "vt" && body.mode !== "file") {
|
|
155
|
+
return c.json({ error: "mode must be vt or file" }, 400);
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
await secrets.rotateKek(body.mode);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
return c.json({ error: String(err) }, 500);
|
|
162
|
+
}
|
|
163
|
+
return c.json(secretsStatus());
|
|
164
|
+
});
|
|
165
|
+
}
|