@a-t-h-i/bot-lobby 0.6.12 → 0.6.13
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 +11 -3
- package/package.json +31 -4
- package/src/ask/tool.ts +2 -1
- package/src/lobby/keys.ts +14 -1
- package/src/lobby/mini.ts +9 -1
- package/src/lobby/models/metrics.ts +14 -0
- package/src/lobby/planner.ts +1 -1
- package/src/lobby/prompt-hub.ts +161 -0
- package/src/lobby/prompts.ts +35 -0
- package/src/lobby/runtime.ts +115 -566
- package/src/lobby/service.ts +655 -0
- package/src/lobby/tabs/home.ts +1 -1
- package/src/lobby/tabs/metrics.ts +4 -6
- package/src/lobby/topics.ts +78 -0
- package/src/lobby/view.ts +29 -24
- package/src/pi/commands.ts +8 -2
- package/src/pi/owner.ts +2 -0
- package/src/pi/settings-ui.ts +52 -1
- package/src/pi/tools.ts +4 -3
- package/src/schemas/configuration.ts +33 -1
- package/src/state/changes.ts +1 -1
- package/src/state/persistence.ts +1 -1
- package/src/state/presence.ts +2 -0
- package/src/webui/api/excalidraw.ts +105 -0
- package/src/webui/api/gh-error.ts +17 -0
- package/src/webui/api/git.ts +98 -0
- package/src/webui/api/index.ts +379 -0
- package/src/webui/api/issues.ts +50 -0
- package/src/webui/api/knowledge.ts +79 -0
- package/src/webui/api/lobby.ts +91 -0
- package/src/webui/api/metrics.ts +63 -0
- package/src/webui/api/plan-facts.ts +29 -0
- package/src/webui/api/planner.ts +98 -0
- package/src/webui/api/plans.ts +44 -0
- package/src/webui/api/prompts.ts +35 -0
- package/src/webui/api/quickfix.ts +48 -0
- package/src/webui/api/sessions.ts +114 -0
- package/src/webui/api/settings.ts +93 -0
- package/src/webui/api/status.ts +55 -0
- package/src/webui/api/tasks.ts +204 -0
- package/src/webui/auth.ts +153 -0
- package/src/webui/command.ts +152 -0
- package/src/webui/dev/fake-service.ts +546 -0
- package/src/webui/dev/fixtures.ts +180 -0
- package/src/webui/dev/serve.ts +54 -0
- package/src/webui/events.ts +207 -0
- package/src/webui/files.ts +88 -0
- package/src/webui/notices.ts +61 -0
- package/src/webui/open.ts +35 -0
- package/src/webui/protocol.ts +565 -0
- package/src/webui/server.ts +315 -0
- package/src/webui/static.ts +125 -0
- package/webui/dist/assets/index-Bk-lpAGm.css +2 -0
- package/webui/dist/assets/index-E6MPcbdC.js +80 -0
- package/webui/dist/build.json +494 -0
- package/webui/dist/icon-192.png +0 -0
- package/webui/dist/icon-512.png +0 -0
- package/webui/dist/index.html +18 -0
- package/webui/dist/manifest.webmanifest +36 -0
- package/webui/dist/offline.html +57 -0
- package/webui/dist/sw.js +88 -0
- package/webui/dist/vite.svg +1 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `status.get`: everything the page shell needs before it reads a tab —
|
|
3
|
+
* workspace, session, tabs, shortcuts and open windows. Tab ids and labels
|
|
4
|
+
* come from the shared strings, shortcuts from the shared key map (with the
|
|
5
|
+
* web-only tab cycling defaults), so the page matches the terminal exactly.
|
|
6
|
+
*/
|
|
7
|
+
import { loadConfig } from "../../state/project.ts";
|
|
8
|
+
import { LOBBY_ACTIONS, keyLabel, webKeyMap } from "../../lobby/keys.ts";
|
|
9
|
+
import { TAB_LABELS, visibleTabs } from "../../lobby/prompts.ts";
|
|
10
|
+
import type { ApiContext } from "./index.ts";
|
|
11
|
+
import type { KeyInfo, StatusInfo, TabInfo } from "../protocol.ts";
|
|
12
|
+
|
|
13
|
+
function tabs(issuesEnabled: boolean): TabInfo[] {
|
|
14
|
+
return visibleTabs(issuesEnabled).map((id, index) => ({ id, label: TAB_LABELS[id], key: `Alt+${index + 1}` }));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function keys(overrides: Readonly<Record<string, string>>): KeyInfo[] {
|
|
18
|
+
const map = webKeyMap(overrides);
|
|
19
|
+
return (Object.keys(LOBBY_ACTIONS) as Array<keyof typeof LOBBY_ACTIONS>).map((action) => ({
|
|
20
|
+
action,
|
|
21
|
+
key: map[action],
|
|
22
|
+
label: keyLabel(map[action]),
|
|
23
|
+
help: LOBBY_ACTIONS[action].help,
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether pi itself is asking in the terminal (a later surface reports it; false until one does). */
|
|
28
|
+
function terminalDialog(ctx: ApiContext): boolean {
|
|
29
|
+
const extra = ctx.service as unknown as { terminalDialog?: () => boolean };
|
|
30
|
+
try {
|
|
31
|
+
return extra.terminalDialog?.() ?? false;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The page shell's first call. */
|
|
38
|
+
export function statusGet(ctx: ApiContext): StatusInfo {
|
|
39
|
+
const service = ctx.service;
|
|
40
|
+
const issuesEnabled = service.issuesEnabled();
|
|
41
|
+
const workspace = service.workspace?.() ?? { name: "bot-lobby" };
|
|
42
|
+
return {
|
|
43
|
+
workspace: { name: workspace.name, ...(workspace.branch ? { branch: workspace.branch } : {}) },
|
|
44
|
+
...(workspace.branch ? { branch: workspace.branch } : {}),
|
|
45
|
+
sessionId: service.sessionId() ?? undefined,
|
|
46
|
+
sessionName: service.sessionName() ?? undefined,
|
|
47
|
+
busy: service.masterBusy(),
|
|
48
|
+
terminalDialog: terminalDialog(ctx),
|
|
49
|
+
port: ctx.port,
|
|
50
|
+
issuesEnabled,
|
|
51
|
+
tabs: tabs(issuesEnabled),
|
|
52
|
+
keys: keys(loadConfig().lobby.keys),
|
|
53
|
+
windows: [],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Tasks tab over HTTP: rows, archived tasks, plan comments, and the row
|
|
3
|
+
* actions (comment, archive, restore, delete, auto mode, message). Actions
|
|
4
|
+
* answer with the same notice text the terminal shows.
|
|
5
|
+
*
|
|
6
|
+
* The row shape mirrors `taskRows` in `src/lobby/tabs/tasks.ts`, whose pure
|
|
7
|
+
* data part is rebuilt here instead of imported: that module draws through
|
|
8
|
+
* `layout.ts` and would load the TUI (`@earendil-works/pi-tui`) into the
|
|
9
|
+
* server. The one type import above is erased at compile time.
|
|
10
|
+
*/
|
|
11
|
+
import { TERMINAL_STATES, taskRequest, type Task } from "../../schemas/task.ts";
|
|
12
|
+
import type { PlannedTask } from "../../state/backlog.ts";
|
|
13
|
+
import { describeRun, runFromLog } from "../../pi/run-summary.ts";
|
|
14
|
+
import { pendingApprovals } from "../../workflow/approvals.ts";
|
|
15
|
+
import type { TaskRow } from "../../lobby/tabs/tasks.ts";
|
|
16
|
+
import type { TaskDetail } from "../protocol.ts";
|
|
17
|
+
import type { ApiContext } from "./index.ts";
|
|
18
|
+
import { fail } from "./index.ts";
|
|
19
|
+
import { taskSteps } from "./plan-facts.ts";
|
|
20
|
+
import type { TaskSection } from "../../lobby/tabs/tasks.ts";
|
|
21
|
+
|
|
22
|
+
interface RowContext {
|
|
23
|
+
names: ReadonlyMap<string, string>;
|
|
24
|
+
auto: ReadonlySet<string>;
|
|
25
|
+
live: ReadonlySet<string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function checkOf(task: Task): TaskRow["check"] {
|
|
29
|
+
if (task.state === "completed") return "done";
|
|
30
|
+
if (task.state === "abandoned") return "dropped";
|
|
31
|
+
return "open";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function progressOf(task: Task): TaskRow["progress"] {
|
|
35
|
+
const steps = taskSteps(task);
|
|
36
|
+
if (steps.length === 0) return undefined;
|
|
37
|
+
return { done: steps.filter((step) => step.status === "done").length, total: steps.length };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** How long ago, as `3h` (mirrors the lobby layout's `ago`). */
|
|
41
|
+
function ago(ms: number): string {
|
|
42
|
+
if (!Number.isFinite(ms) || ms < 5_000) return "now";
|
|
43
|
+
const seconds = Math.floor(ms / 1000);
|
|
44
|
+
if (seconds < 60) return `${seconds}s`;
|
|
45
|
+
const minutes = Math.floor(seconds / 60);
|
|
46
|
+
if (minutes < 60) return `${minutes}m`;
|
|
47
|
+
const hours = Math.floor(minutes / 60);
|
|
48
|
+
if (hours < 48) return `${hours}h`;
|
|
49
|
+
return `${Math.floor(hours / 24)}d`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ownerOf(task: Task, sessionId: string | undefined, ctx: RowContext): string {
|
|
53
|
+
if (!task.ownerSessionId) return "not running";
|
|
54
|
+
if (task.ownerSessionId === sessionId) return "this session";
|
|
55
|
+
const name = ctx.names.get(task.ownerSessionId);
|
|
56
|
+
if (name) return name === task.title ? "background" : `background · ${name}`;
|
|
57
|
+
return !ctx.live.has(task.ownerSessionId) ? "not running" : `session ${task.ownerSessionId.slice(0, 8)}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function rowOf(task: Task, section: TaskSection, ctx: RowContext, sessionId: string | undefined, now: number): TaskRow {
|
|
61
|
+
const progress = section === "recent" ? undefined : progressOf(task);
|
|
62
|
+
return {
|
|
63
|
+
kind: "task",
|
|
64
|
+
id: task.id,
|
|
65
|
+
title: task.title,
|
|
66
|
+
section,
|
|
67
|
+
status: task.state,
|
|
68
|
+
...(task.paused && section !== "recent" ? { paused: true } : {}),
|
|
69
|
+
check: checkOf(task),
|
|
70
|
+
...(progress ? { progress } : {}),
|
|
71
|
+
...(section === "others" ? { owner: ownerOf(task, sessionId, ctx) } : {}),
|
|
72
|
+
...(section === "recent" ? { age: ago(now - Date.parse(task.updatedAt)) } : {}),
|
|
73
|
+
...(section !== "recent" && ctx.auto.has(task.id) ? { auto: true } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Rows in display order, mirroring the terminal's `taskRows` (without its search narrowing). */
|
|
78
|
+
function buildRows(tasks: readonly Task[], plans: readonly PlannedTask[], sessionId: string | undefined, ctx: RowContext, now: number, archived: readonly Task[]): TaskRow[] {
|
|
79
|
+
const active = tasks.filter((task) => !TERMINAL_STATES.includes(task.state));
|
|
80
|
+
return [
|
|
81
|
+
...active.filter((task) => sessionId && task.ownerSessionId === sessionId).map((task) => rowOf(task, "mine", ctx, sessionId, now)),
|
|
82
|
+
...active.filter((task) => !sessionId || task.ownerSessionId !== sessionId).map((task) => rowOf(task, "others", ctx, sessionId, now)),
|
|
83
|
+
...plans.filter((plan) => plan.status === "pending").map((plan): TaskRow => ({
|
|
84
|
+
kind: "plan",
|
|
85
|
+
id: plan.id,
|
|
86
|
+
title: plan.split ? `${plan.title} (${plan.split.part}/${plan.split.of})` : plan.title,
|
|
87
|
+
section: "pending",
|
|
88
|
+
status: "pending",
|
|
89
|
+
check: "open",
|
|
90
|
+
...(plan.createdAt ? { age: ago(now - Date.parse(plan.createdAt)) } : {}),
|
|
91
|
+
...(plan.issue ? { issue: plan.issue.number } : {}),
|
|
92
|
+
})),
|
|
93
|
+
...tasks.filter((task) => TERMINAL_STATES.includes(task.state)).map((task) => rowOf(task, "recent", ctx, sessionId, now)),
|
|
94
|
+
...archived.map((task): TaskRow => ({
|
|
95
|
+
kind: "archived",
|
|
96
|
+
id: task.id,
|
|
97
|
+
title: task.title,
|
|
98
|
+
section: "archived",
|
|
99
|
+
status: task.state,
|
|
100
|
+
check: checkOf(task),
|
|
101
|
+
age: ago(now - Date.parse(task.archivedAt ?? task.updatedAt)),
|
|
102
|
+
})),
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function contextOf(ctx: ApiContext, tasks: readonly Task[]): RowContext {
|
|
107
|
+
const names = new Map<string, string>();
|
|
108
|
+
for (const session of ctx.service.sessions()) {
|
|
109
|
+
if (session.sessionId && session.alive) names.set(session.sessionId, session.name);
|
|
110
|
+
}
|
|
111
|
+
const auto = new Set(tasks.filter((task) => !TERMINAL_STATES.includes(task.state) && ctx.service.isAuto(task.id)).map((task) => task.id));
|
|
112
|
+
return { names, auto, live: new Set(ctx.service.liveSessions().map((session) => session.sessionId)) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Every task, plan and recent row, oldest groups first like the terminal. */
|
|
116
|
+
export function tasksList(ctx: ApiContext): { rows: TaskRow[] } {
|
|
117
|
+
const tasks = ctx.service.tasks();
|
|
118
|
+
const rows = buildRows(tasks, ctx.service.plans(), ctx.service.sessionId(), contextOf(ctx, tasks), Date.now(), []);
|
|
119
|
+
return { rows };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Archived tasks as rows, most recently archived first. */
|
|
123
|
+
export function tasksArchived(ctx: ApiContext): { rows: TaskRow[] } {
|
|
124
|
+
const archived = ctx.service.archivedTasks();
|
|
125
|
+
const rows = archived.map((task): TaskRow => ({
|
|
126
|
+
kind: "archived",
|
|
127
|
+
id: task.id,
|
|
128
|
+
title: task.title,
|
|
129
|
+
section: "archived",
|
|
130
|
+
status: task.state,
|
|
131
|
+
check: checkOf(task),
|
|
132
|
+
age: ago(Date.now() - Date.parse(task.archivedAt ?? task.updatedAt)),
|
|
133
|
+
}));
|
|
134
|
+
return { rows };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** One task's plan comments, oldest first. */
|
|
138
|
+
export function tasksComments(body: { taskId: string }, ctx: ApiContext): { comments: unknown[] } {
|
|
139
|
+
return { comments: ctx.service.comments(body.taskId) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Comment on a task's plan; blank text is a no-op notice like the lobby's. */
|
|
143
|
+
export function tasksComment(body: { taskId: string; text: string }, ctx: ApiContext): { notice?: string } {
|
|
144
|
+
if (!body.text.trim()) return { notice: "type something first" };
|
|
145
|
+
return { notice: ctx.service.comment(body.taskId, body.text) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Archive, restore or delete a task; each answers the terminal's notice. */
|
|
149
|
+
export function tasksArchive(body: { taskId: string }, ctx: ApiContext): { notice: string } {
|
|
150
|
+
return { notice: ctx.service.archiveTask(body.taskId) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Bring an archived task back to the list. */
|
|
154
|
+
export function tasksRestore(body: { taskId: string }, ctx: ApiContext): { notice: string } {
|
|
155
|
+
return { notice: ctx.service.restoreTask(body.taskId) };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Delete a task for good, from the list or the archive. */
|
|
159
|
+
export function tasksDelete(body: { taskId: string; where: "list" | "archive" }, ctx: ApiContext): { notice: string } {
|
|
160
|
+
return { notice: ctx.service.deleteTask(body.taskId, body.where) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Switch auto mode for a task; unknown or finished tasks are refused. */
|
|
164
|
+
export function tasksAuto(body: { taskId: string; on: boolean }, ctx: ApiContext): { notice: string; on: boolean } {
|
|
165
|
+
const task = [...ctx.service.tasks(), ...ctx.service.archivedTasks()].find((entry) => entry.id === body.taskId);
|
|
166
|
+
if (!task) fail(404, "not_found", `no task ${body.taskId}`);
|
|
167
|
+
if (TERMINAL_STATES.includes((task as Task).state)) fail(400, "bad_request", `${body.taskId} is ${(task as Task).state}; auto mode applies to a task under way`);
|
|
168
|
+
ctx.service.setAuto(body.taskId, body.on);
|
|
169
|
+
const id = body.taskId;
|
|
170
|
+
return { notice: body.on ? `auto mode on — the oracle drives ${id} to completion without asking` : `auto mode off — the oracle asks you again on ${id}`, on: body.on };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Leave a message for a task's oracle; blank text is a no-op notice. */
|
|
174
|
+
export function tasksMessage(body: { taskId: string; text: string }, ctx: ApiContext): { notice: string } {
|
|
175
|
+
if (!body.text.trim()) return { notice: "type something first" };
|
|
176
|
+
return { notice: ctx.service.sendToTask(body.taskId, body.text) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** How many recent runs the detail lists (the terminal's count). */
|
|
180
|
+
const DETAIL_RUNS = 6;
|
|
181
|
+
|
|
182
|
+
/** The terminal's "Waiting on" approvals: `kind` plus `for <domain>: <detail>`. */
|
|
183
|
+
function waitingOf(task: Task): TaskDetail["waiting"] {
|
|
184
|
+
return pendingApprovals(task).map((approval) => ({ kind: approval.kind, detail: `for ${approval.domain}: ${approval.detail}` }));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** One task read whole: request, plan or proposal, step checklist, waits and recent runs. */
|
|
188
|
+
export function tasksGet(body: { taskId: string }, ctx: ApiContext): TaskDetail {
|
|
189
|
+
const task = [...ctx.service.tasks(), ...ctx.service.archivedTasks()].find((entry) => entry.id === body.taskId);
|
|
190
|
+
if (!task) fail(404, "not_found", `no task ${body.taskId}`);
|
|
191
|
+
const found = task as Task;
|
|
192
|
+
const request = taskRequest(found);
|
|
193
|
+
const now = Date.now();
|
|
194
|
+
return {
|
|
195
|
+
...(request !== found.title ? { request } : {}),
|
|
196
|
+
...(!found.plan && found.proposal ? { proposal: found.proposal } : {}),
|
|
197
|
+
...(found.plan ? { plan: found.plan } : {}),
|
|
198
|
+
steps: taskSteps(found).map((step) => ({ text: step.text, status: step.status === "pending" ? "open" : step.status })),
|
|
199
|
+
amendments: [...found.amendments],
|
|
200
|
+
waiting: waitingOf(found),
|
|
201
|
+
blockers: found.blockers.map((blocker) => ({ reason: blocker.reason, need: blocker.need })),
|
|
202
|
+
runs: (found.runLog ?? []).slice(-DETAIL_RUNS).map((entry) => describeRun(runFromLog(entry, found.id), now)),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may use the loopback server: whoever holds the link. The link carries
|
|
3
|
+
* a token in its `#fragment` (never sent to any server); the page trades it
|
|
4
|
+
* once for an HttpOnly, SameSite=Strict cookie. Pure helpers plus the secret
|
|
5
|
+
* file; the server wires them to HTTP.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
8
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { globalConfigDir } from "../state/project.ts";
|
|
11
|
+
|
|
12
|
+
/** Largest JSON body the server reads; past it the request is refused. */
|
|
13
|
+
export const MAX_BODY_BYTES = 1_000_000;
|
|
14
|
+
/** Session cookie name. */
|
|
15
|
+
export const COOKIE_NAME = "bl_session";
|
|
16
|
+
/** Wrong link tokens per minute before the server locks logins out. */
|
|
17
|
+
export const LOGIN_FAILURES_PER_MINUTE = 10;
|
|
18
|
+
/** The lockout window (and the failure window) in milliseconds. */
|
|
19
|
+
export const LOGIN_WINDOW_MS = 60_000;
|
|
20
|
+
|
|
21
|
+
function derive(secret: Buffer, purpose: string): string {
|
|
22
|
+
return createHmac("sha256", secret).update(purpose).digest("base64url");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The token the link carries (`#token=…`). */
|
|
26
|
+
export function linkToken(secret: Buffer): string {
|
|
27
|
+
return derive(secret, "bot-lobby link v1");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The cookie value a signed-in browser holds. */
|
|
31
|
+
export function sessionValue(secret: Buffer): string {
|
|
32
|
+
return derive(secret, "bot-lobby session v1");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Constant-time string compare (both sides hashed first, so lengths match). */
|
|
36
|
+
export function safeEqual(a: string, b: string): boolean {
|
|
37
|
+
return timingSafeEqual(createHash("sha256").update(a).digest(), createHash("sha256").update(b).digest());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Path of the secret file: `web.json` under the user-global config dir. */
|
|
41
|
+
export function webSecretPath(): string {
|
|
42
|
+
return join(globalConfigDir(), "web.json");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Leave a file readable only by its owner; fix it when it is wider. */
|
|
46
|
+
function ensurePrivate(path: string): void {
|
|
47
|
+
try {
|
|
48
|
+
if ((statSync(path).mode & 0o077) !== 0) chmodSync(path, 0o600);
|
|
49
|
+
} catch {
|
|
50
|
+
// A file that cannot be inspected is replaced by the caller.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function writeSecret(path: string): Buffer {
|
|
55
|
+
const secret = randomBytes(32);
|
|
56
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
57
|
+
writeFileSync(path, JSON.stringify({ secret: secret.toString("base64") }), { mode: 0o600 });
|
|
58
|
+
ensurePrivate(path);
|
|
59
|
+
return secret;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readSecret(path: string): Buffer | undefined {
|
|
63
|
+
try {
|
|
64
|
+
const raw = JSON.parse(readFileSync(path, "utf8")) as { secret?: unknown };
|
|
65
|
+
if (typeof raw.secret !== "string") return undefined;
|
|
66
|
+
const secret = Buffer.from(raw.secret, "base64");
|
|
67
|
+
return secret.length === 32 ? secret : undefined;
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The loopback secret, created on first use. A corrupt file is replaced
|
|
75
|
+
* (`replaced` says so, and the caller logs a notice); a file that is wider
|
|
76
|
+
* than 0600 is narrowed in place.
|
|
77
|
+
*/
|
|
78
|
+
export function loadOrCreateSecret(): { secret: Buffer; created: boolean; replaced: boolean } {
|
|
79
|
+
const path = webSecretPath();
|
|
80
|
+
if (!existsSync(path)) return { secret: writeSecret(path), created: true, replaced: false };
|
|
81
|
+
const secret = readSecret(path);
|
|
82
|
+
if (!secret) return { secret: writeSecret(path), created: false, replaced: true };
|
|
83
|
+
ensurePrivate(path);
|
|
84
|
+
return { secret, created: false, replaced: false };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A new secret, signing everyone out (the next step's `web reset`). */
|
|
88
|
+
export function resetSecret(): Buffer {
|
|
89
|
+
return writeSecret(webSecretPath());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The `Set-Cookie` value for a signed-in browser. */
|
|
93
|
+
export function sessionCookie(value: string): string {
|
|
94
|
+
return `${COOKIE_NAME}=${value}; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One cookie's value from a `Cookie` header. */
|
|
98
|
+
export function cookieOf(header: string | undefined, name: string): string | undefined {
|
|
99
|
+
if (!header) return undefined;
|
|
100
|
+
for (const part of header.split(";")) {
|
|
101
|
+
const index = part.indexOf("=");
|
|
102
|
+
if (index < 0) continue;
|
|
103
|
+
if (part.slice(0, index).trim() === name) return part.slice(index + 1).trim();
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Whether the request carries this server's session cookie. */
|
|
109
|
+
export function isSignedIn(cookieHeader: string | undefined, session: string): boolean {
|
|
110
|
+
const value = cookieOf(cookieHeader, COOKIE_NAME);
|
|
111
|
+
return value !== undefined && value.length > 0 && safeEqual(value, session);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Hosts a loopback server on `port` answers to (DNS rebinding names anything else). */
|
|
115
|
+
export function allowedHosts(port: number): Set<string> {
|
|
116
|
+
return new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Whether `Host` names this loopback server. */
|
|
120
|
+
export function isHostAllowed(host: string | undefined, port: number): boolean {
|
|
121
|
+
return host !== undefined && allowedHosts(port).has(host.trim().toLowerCase());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Origins this loopback server answers to. */
|
|
125
|
+
function allowedOrigins(port: number): Set<string> {
|
|
126
|
+
return new Set([...allowedHosts(port)].map((host) => `http://${host}`));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A foreign `Origin` or a cross-site `Sec-Fetch-Site` (never another website). */
|
|
130
|
+
export function isCrossSite(origin: string | undefined, site: string | undefined, port: number): boolean {
|
|
131
|
+
if (origin !== undefined && origin !== "" && !allowedOrigins(port).has(origin)) return true;
|
|
132
|
+
return site === "cross-site" || site === "same-site";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Wrong link tokens in the last minute; past the limit logins lock out for that minute. */
|
|
136
|
+
export class LoginGuard {
|
|
137
|
+
private failures: number[] = [];
|
|
138
|
+
private readonly now: () => number;
|
|
139
|
+
constructor(now: () => number = Date.now) {
|
|
140
|
+
this.now = now;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Whether logins are locked out right now. */
|
|
144
|
+
blocked(at = this.now()): boolean {
|
|
145
|
+
this.failures = this.failures.filter((seen) => at - seen < LOGIN_WINDOW_MS);
|
|
146
|
+
return this.failures.length >= LOGIN_FAILURES_PER_MINUTE;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Record a wrong token. */
|
|
150
|
+
noteFailure(at = this.now()): void {
|
|
151
|
+
this.failures = [...this.failures.filter((seen) => at - seen < LOGIN_WINDOW_MS), at];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/bot-lobby web`: the loopback server from Pi. `web` starts it (once per
|
|
3
|
+
* process), prints the link and opens the browser; `web link` prints only
|
|
4
|
+
* the link, `web stop` frees the port, `web reset` mints a new secret and
|
|
5
|
+
* returns the new link. Errors become a notice, never a throw; the link
|
|
6
|
+
* itself carries the secret, so notices never name it beyond the link.
|
|
7
|
+
*/
|
|
8
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { loadConfig } from "../state/project.ts";
|
|
10
|
+
import { currentLobbyService } from "../lobby/runtime.ts";
|
|
11
|
+
import type { LobbyService } from "../lobby/service.ts";
|
|
12
|
+
import { isSubagentProcess } from "../pi/quiet.ts";
|
|
13
|
+
import { heartbeat } from "../pi/owner.ts";
|
|
14
|
+
import { currentWebServer, startWebServer } from "./server.ts";
|
|
15
|
+
import { openBrowser } from "./open.ts";
|
|
16
|
+
|
|
17
|
+
export type WebSubcommand = "start" | "stop" | "link" | "reset";
|
|
18
|
+
|
|
19
|
+
export interface WebCommandDeps {
|
|
20
|
+
service?: LobbyService;
|
|
21
|
+
open?: (url: string) => boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The freshest link: the server's `link` is the one it started with, so a
|
|
26
|
+
* `reset` after it would leave `link`/`web link` printing the old secret.
|
|
27
|
+
* The commands below keep the newest link here instead.
|
|
28
|
+
*/
|
|
29
|
+
let latestLink: string | undefined;
|
|
30
|
+
|
|
31
|
+
/** The link to print: the newest one from this module, else the server's own. */
|
|
32
|
+
export function webLink(): string | undefined {
|
|
33
|
+
const running = currentWebServer();
|
|
34
|
+
if (!running) {
|
|
35
|
+
latestLink = undefined;
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
return latestLink ?? running.link;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The subcommand of `web stop|link|reset` (bare `web` starts); undefined when the word is none of those. */
|
|
42
|
+
export function webAction(word: string | undefined): WebSubcommand | undefined {
|
|
43
|
+
if (!word) return "start";
|
|
44
|
+
if (word === "stop" || word === "link" || word === "reset") return word;
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveService(deps?: WebCommandDeps): LobbyService | undefined {
|
|
49
|
+
return deps?.service ?? currentLobbyService();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function openLink(url: string, deps?: WebCommandDeps): void {
|
|
53
|
+
if (!loadConfig().lobby.web.openBrowser) return;
|
|
54
|
+
try {
|
|
55
|
+
(deps?.open ?? openBrowser)(url);
|
|
56
|
+
} catch {
|
|
57
|
+
// A browser that will not open only means the link stays on screen.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function startCommand(ctx: ExtensionCommandContext, deps?: WebCommandDeps): Promise<void> {
|
|
62
|
+
const running = currentWebServer();
|
|
63
|
+
if (running) {
|
|
64
|
+
ctx.ui.notify(`bot-lobby web is already running: ${webLink()}`, "info");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const service = resolveService(deps);
|
|
68
|
+
if (!service) {
|
|
69
|
+
ctx.ui.notify("bot-lobby web needs the lobby service; open the lobby first.", "warning");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const server = await startWebServer({ service, port: loadConfig().lobby.web.port });
|
|
73
|
+
latestLink = server.link;
|
|
74
|
+
heartbeat();
|
|
75
|
+
ctx.ui.notify(`bot-lobby web: ${server.link}`, "info");
|
|
76
|
+
openLink(server.link, deps);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function linkCommand(ctx: ExtensionCommandContext): void {
|
|
80
|
+
const link = webLink();
|
|
81
|
+
if (link) ctx.ui.notify(`bot-lobby web: ${link}`, "info");
|
|
82
|
+
else ctx.ui.notify("bot-lobby web is not running; /bot-lobby web starts it.", "warning");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function stopCommand(ctx: ExtensionCommandContext): Promise<void> {
|
|
86
|
+
const running = currentWebServer();
|
|
87
|
+
if (!running) {
|
|
88
|
+
ctx.ui.notify("bot-lobby web is not running.", "warning");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
await running.close();
|
|
92
|
+
latestLink = undefined;
|
|
93
|
+
heartbeat();
|
|
94
|
+
ctx.ui.notify("bot-lobby web stopped.", "info");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function resetCommand(ctx: ExtensionCommandContext): void {
|
|
98
|
+
const running = currentWebServer();
|
|
99
|
+
if (!running) {
|
|
100
|
+
ctx.ui.notify("bot-lobby web is not running; /bot-lobby web starts it.", "warning");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const link = running.reset();
|
|
104
|
+
latestLink = link;
|
|
105
|
+
heartbeat();
|
|
106
|
+
ctx.ui.notify(`bot-lobby web: the link changed: ${link}`, "info");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Run one `web` subcommand; a failure is a notice, never a throw. */
|
|
110
|
+
export async function webCommand(ctx: ExtensionCommandContext, word: string | undefined, deps?: WebCommandDeps): Promise<void> {
|
|
111
|
+
const action = webAction(word);
|
|
112
|
+
if (!action) {
|
|
113
|
+
ctx.ui.notify("Usage: /bot-lobby web [stop|link|reset]", "warning");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
if (action === "start") await startCommand(ctx, deps);
|
|
118
|
+
else if (action === "stop") await stopCommand(ctx);
|
|
119
|
+
else if (action === "reset") resetCommand(ctx);
|
|
120
|
+
else linkCommand(ctx);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
ctx.ui.notify(`bot-lobby web failed — ${(error as Error).message}`, "warning");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* On `session_start`: follow the fresh lobby service, and start the server
|
|
128
|
+
* when the config asks for it. Silent (no notice, no browser): the status
|
|
129
|
+
* line shows the link once the server runs.
|
|
130
|
+
*/
|
|
131
|
+
export function webSessionStarted(deps?: WebCommandDeps): void {
|
|
132
|
+
if (isSubagentProcess()) return;
|
|
133
|
+
const service = resolveService(deps);
|
|
134
|
+
const running = currentWebServer();
|
|
135
|
+
if (running) {
|
|
136
|
+
if (service) running.rebind(service);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (!service || !loadConfig().lobby.web.enabled) return;
|
|
140
|
+
startWebServer({ service, port: loadConfig().lobby.web.port })
|
|
141
|
+
.then((server) => {
|
|
142
|
+
latestLink = server.link;
|
|
143
|
+
heartbeat();
|
|
144
|
+
})
|
|
145
|
+
.catch(() => {});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Wire the web server's lifecycle; called with the other session wiring. */
|
|
149
|
+
export function registerWebServer(pi: ExtensionAPI): void {
|
|
150
|
+
if (isSubagentProcess()) return;
|
|
151
|
+
pi.on("session_start", () => webSessionStarted());
|
|
152
|
+
}
|