@pablopunk/webr 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +17 -0
- package/bin/webr.mjs +11 -0
- package/dist/client/_astro/App.BxlQhLhN.css +1 -0
- package/dist/client/_astro/App.CzxEZ_Dw.js +2 -0
- package/dist/client/_astro/ClientRouter.astro_astro_type_script_index_0_lang.CTgtIFyY.js +1 -0
- package/dist/client/_astro/addon-fit.jgKqzwky.js +1 -0
- package/dist/client/_astro/addon-web-links.C9lyQRxI.js +1 -0
- package/dist/client/_astro/client.BDkzSRUN.js +1 -0
- package/dist/client/_astro/client.Bbg9OBwT.js +1 -0
- package/dist/client/_astro/client.CLSsMr2g.js +9 -0
- package/dist/client/_astro/jetbrains-mono.CFJaiWaY.woff2 +0 -0
- package/dist/client/_astro/symbols-nerd.CEdkKAK8.woff2 +0 -0
- package/dist/client/_astro/xterm.B-l5AIf6.js +7 -0
- package/dist/client/apple-touch-icon.png +0 -0
- package/dist/client/brand-mark.png +0 -0
- package/dist/client/favicon-16.png +0 -0
- package/dist/client/favicon-32.png +0 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/icon-192.png +0 -0
- package/dist/client/icon-512.png +0 -0
- package/dist/client/icon-maskable-512.png +0 -0
- package/dist/client/manifest.webmanifest +30 -0
- package/dist/server/chunks/App_BmsGAwGf.mjs +4314 -0
- package/dist/server/chunks/_id__u0ueX6GO.mjs +34 -0
- package/dist/server/chunks/_tab__DL6wyr-j.mjs +35 -0
- package/dist/server/chunks/_virtual_astro_server-island-manifest_C1Q2srgE.mjs +5 -0
- package/dist/server/chunks/_virtual_astro_session-driver_DS5V7T-N.mjs +6 -0
- package/dist/server/chunks/compiler_CpXox_xS.mjs +35 -0
- package/dist/server/chunks/console_CucE1SwS.mjs +170 -0
- package/dist/server/chunks/errors_BgIp4W2B.mjs +383 -0
- package/dist/server/chunks/index_DyyPce_k.mjs +22 -0
- package/dist/server/chunks/new_G7XChx5p.mjs +33 -0
- package/dist/server/chunks/node_BlixNyYV.mjs +1816 -0
- package/dist/server/chunks/noop-entrypoint_Z3zFhrGC.mjs +4 -0
- package/dist/server/chunks/remote_BxuVLM0r.mjs +169 -0
- package/dist/server/chunks/server_DS45LC4q.mjs +2496 -0
- package/dist/server/chunks/settings_DLV2Js3e.mjs +31 -0
- package/dist/server/chunks/sharp_CgNvPCpY.mjs +152 -0
- package/dist/server/chunks/vnode-children_CRmaSs5G.mjs +24 -0
- package/dist/server/entry.mjs +5837 -0
- package/dist/server/virtual_astro_middleware.mjs +4 -0
- package/drizzle/0000_gateway.sql +13 -0
- package/drizzle/0001_device_sessions.sql +9 -0
- package/drizzle/0002_audit_events.sql +7 -0
- package/drizzle/meta/_journal.json +1 -0
- package/package.json +88 -0
- package/server/cli.ts +93 -0
- package/server/command.ts +9 -0
- package/server/entry.ts +3 -0
- package/server/herdr-check.ts +37 -0
- package/server/home.ts +4 -0
- package/server/log-file.ts +52 -0
- package/server/package-info.ts +6 -0
- package/server/plugin/config.ts +14 -0
- package/server/plugin/dev.ts +27 -0
- package/server/plugin/files.ts +10 -0
- package/server/plugin/herdr.ts +33 -0
- package/server/plugin/index.ts +68 -0
- package/server/plugin/probe.ts +17 -0
- package/server/plugin/stop.ts +39 -0
- package/server/port.ts +8 -0
- package/server/run.ts +35 -0
- package/server/ssr.ts +23 -0
- package/server/start.ts +5 -0
- package/server/update/hint.ts +26 -0
- package/server/update/index.ts +33 -0
- package/server/update/latest.ts +33 -0
- package/server/update/package-manager.ts +24 -0
- package/server/update/versions.ts +15 -0
- package/src/lib/avatars.ts +15 -0
- package/src/lib/launch.ts +16 -0
- package/src/lib/machines.ts +41 -0
- package/src/lib/mock-store.ts +82 -0
- package/src/lib/models.ts +139 -0
- package/src/lib/project-icons.ts +120 -0
- package/src/lib/terminal-split.ts +23 -0
- package/src/lib/uuid.ts +10 -0
- package/src/server/auth/access.ts +22 -0
- package/src/server/auth/audit.ts +15 -0
- package/src/server/auth/auth.ts +134 -0
- package/src/server/auth/pair-page.ts +85 -0
- package/src/server/auth/pairing.ts +91 -0
- package/src/server/auth/presence.ts +8 -0
- package/src/server/auth/sessions.ts +48 -0
- package/src/server/directories.ts +21 -0
- package/src/server/dist.ts +3 -0
- package/src/server/host.ts +166 -0
- package/src/server/perf-log.ts +23 -0
- package/src/server/protocol/deadlines.ts +24 -0
- package/src/server/protocol/native.ts +10 -0
- package/src/server/protocol/ndjson.ts +22 -0
- package/src/server/protocol/socket.ts +81 -0
- package/src/server/protocol/validate.ts +17 -0
- package/src/server/public-urls.ts +17 -0
- package/src/server/runtime/herdr-actions.ts +122 -0
- package/src/server/runtime/launch.ts +55 -0
- package/src/server/runtime/manager.ts +187 -0
- package/src/server/runtime/reconcile.ts +51 -0
- package/src/server/runtime/supervisor.ts +92 -0
- package/src/server/runtime/target.ts +37 -0
- package/src/server/runtime/workspace.ts +24 -0
- package/src/server/storage/database.ts +88 -0
- package/src/server/storage/drizzle-node-sqlite.ts +18 -0
- package/src/server/storage/node-sqlite-client.ts +44 -0
- package/src/server/storage/schema.ts +25 -0
- package/src/server/tailscale-host.ts +17 -0
- package/src/server/terminal/cli.ts +44 -0
- package/src/server/terminal/hub.ts +140 -0
- package/src/server/terminal/writer.ts +38 -0
- package/src/server/transport/herdr-target.ts +148 -0
- package/src/server/transport/icons.ts +65 -0
- package/src/server/transport/identity.ts +5 -0
- package/src/server/transport/local-session.ts +65 -0
- package/src/server/transport/model-discovery.ts +18 -0
- package/src/server/transport/native-locations.ts +17 -0
- package/src/server/transport/process.ts +21 -0
- package/src/server/transport/registry.ts +27 -0
- package/src/server/transport/ssh.ts +47 -0
- package/src/server/uploads.ts +40 -0
- package/src/server/websockets.ts +62 -0
- package/src/shared/agent-kinds.ts +4 -0
- package/src/shared/frame.ts +38 -0
- package/src/shared/projects.ts +1 -0
- package/src/shared/retry.ts +1 -0
- package/src/shared/runtime.ts +33 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
2
|
+
import type { WebSocket } from 'ws';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import type { MetadataDatabase } from '../storage/database';
|
|
5
|
+
import { isLocalRequest, sourceAddress } from './access';
|
|
6
|
+
import { AuditLog } from './audit';
|
|
7
|
+
import { ApproverPresence } from './presence';
|
|
8
|
+
import { pairPage } from './pair-page';
|
|
9
|
+
import { PairingService, RateLimiter, formatInviteToken } from './pairing';
|
|
10
|
+
import { SessionStore, clearedSessionCookie, deviceNameFrom, sessionCookie, type DeviceSession } from './sessions';
|
|
11
|
+
|
|
12
|
+
export type AuthOptions = { publicUrls?: string[] };
|
|
13
|
+
export type Access = { local: boolean; session?: DeviceSession };
|
|
14
|
+
|
|
15
|
+
const publicRoutes: [method: string, path: RegExp][] = [
|
|
16
|
+
['GET', /^\/api\/auth\/state$/],
|
|
17
|
+
['POST', /^\/api\/pair\/request$/],
|
|
18
|
+
['POST', /^\/api\/pair\/request\/[^/]+\/poll$/],
|
|
19
|
+
['POST', /^\/api\/pair\/redeem$/],
|
|
20
|
+
['GET', /^\/[a-z0-9-]+\.(?:png|ico|webmanifest)$/],
|
|
21
|
+
];
|
|
22
|
+
const idParams = z.object({ id: z.uuid() });
|
|
23
|
+
const emptyBody = z.object({}).strict();
|
|
24
|
+
const sourceOf = (request: FastifyRequest) => sourceAddress(request.ip);
|
|
25
|
+
const deviceOf = (request: FastifyRequest) => deviceNameFrom(request.headers['user-agent']);
|
|
26
|
+
|
|
27
|
+
export class Auth {
|
|
28
|
+
readonly sessions: SessionStore;
|
|
29
|
+
readonly pairing = new PairingService();
|
|
30
|
+
readonly audit: AuditLog;
|
|
31
|
+
private readonly presence = new ApproverPresence();
|
|
32
|
+
private readonly accessByRequest = new WeakMap<object, Access>();
|
|
33
|
+
private readonly deviceBySocket = new WeakMap<object, string>();
|
|
34
|
+
|
|
35
|
+
constructor(database: MetadataDatabase, private readonly origin: URL, private readonly options: AuthOptions = {}) {
|
|
36
|
+
this.audit = new AuditLog(database);
|
|
37
|
+
this.sessions = new SessionStore(database, Date.now, this.audit);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
access(request: FastifyRequest): Access { return this.accessByRequest.get(request.raw) ?? { local: false }; }
|
|
41
|
+
|
|
42
|
+
guard(request: FastifyRequest, reply: FastifyReply) {
|
|
43
|
+
const access: Access = { local: isLocalRequest(request.raw), session: this.sessions.authenticate(request.headers.cookie, sourceOf(request)) };
|
|
44
|
+
this.accessByRequest.set(request.raw, access);
|
|
45
|
+
if (access.local || access.session || this.isPublic(request)) return undefined;
|
|
46
|
+
if (request.method === 'GET' && request.headers.accept?.includes('text/html')) return reply.code(401).type('text/html').send(pairPage());
|
|
47
|
+
return reply.code(401).send({ error: 'unauthorized' });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
expectedOrigin(request: FastifyRequest) {
|
|
51
|
+
return `${request.headers.host === this.origin.host ? this.origin.protocol : `${request.protocol}:`}//${request.headers.host}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
routes(app: FastifyInstance) {
|
|
55
|
+
const requestLimiter = new RateLimiter(6, 60_000);
|
|
56
|
+
const redeemLimiter = new RateLimiter(10, 60_000);
|
|
57
|
+
app.websocketServer.on('connection', (socket: WebSocket, request) => { const id = this.accessByRequest.get(request)?.session?.id; if (id) this.deviceBySocket.set(socket, id); });
|
|
58
|
+
|
|
59
|
+
app.get('/api/auth/state', async (request) => { const { local, session } = this.access(request); return { local, authenticated: !!session }; });
|
|
60
|
+
app.post('/api/pair/request', async (request, reply) => {
|
|
61
|
+
if (!requestLimiter.allow(request.ip)) return reply.code(429).send({ error: 'rate_limited' });
|
|
62
|
+
try {
|
|
63
|
+
const created = this.pairing.request(deviceOf(request), sourceOf(request));
|
|
64
|
+
this.audit.record('pair_requested', { source: sourceOf(request), deviceName: deviceOf(request) });
|
|
65
|
+
return created;
|
|
66
|
+
} catch (error) { return reply.code(429).send({ error: error instanceof Error && error.message === 'cooling_down' ? 'cooling_down' : 'too_many_requests' }); }
|
|
67
|
+
});
|
|
68
|
+
app.post('/api/pair/request/:id/poll', async (request, reply) => {
|
|
69
|
+
const { id } = idParams.parse(request.params);
|
|
70
|
+
const { secret } = z.object({ secret: z.string().min(1).max(100) }).strict().parse(request.body);
|
|
71
|
+
const result = this.pairing.claim(id, secret);
|
|
72
|
+
if (result.status === 'approved') this.grant(request, reply, result.deviceName!);
|
|
73
|
+
return result.status === 'pending' ? { status: result.status, approverOnline: this.presence.attentive() } : { status: result.status };
|
|
74
|
+
});
|
|
75
|
+
app.post('/api/pair/redeem', async (request, reply) => {
|
|
76
|
+
if (!redeemLimiter.allow(request.ip)) return reply.code(429).send({ error: 'rate_limited' });
|
|
77
|
+
const { token } = z.object({ token: z.string().min(1).max(64) }).strict().parse(request.body);
|
|
78
|
+
if (!this.pairing.redeemInvite(token)) return reply.code(403).send({ error: 'invalid_token' });
|
|
79
|
+
this.audit.record('invite_redeemed', { source: sourceOf(request), deviceName: deviceOf(request) });
|
|
80
|
+
this.grant(request, reply, deviceOf(request));
|
|
81
|
+
return { ok: true };
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
app.get('/api/pair/pending', async () => { this.presence.seen(); return this.pairing.pending(); });
|
|
85
|
+
for (const [action, state] of [['approve', 'approved'], ['deny', 'denied']] as const) {
|
|
86
|
+
app.post(`/api/pair/:id/${action}`, async (request, reply) => {
|
|
87
|
+
const { id } = idParams.parse(request.params);
|
|
88
|
+
emptyBody.parse(request.body);
|
|
89
|
+
const decided = this.pairing.decide(id, state);
|
|
90
|
+
if (!decided) return reply.code(404).send({ error: 'request_not_found' });
|
|
91
|
+
this.audit.record(state === 'approved' ? 'pair_approved' : 'pair_denied', decided);
|
|
92
|
+
return { [state]: true };
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
app.post('/api/pair/invites', async (request) => {
|
|
96
|
+
const invite = this.pairing.createInvite();
|
|
97
|
+
this.audit.record('invite_created', { source: sourceOf(request) });
|
|
98
|
+
return { token: formatInviteToken(invite.token), expiresAt: invite.expiresAt, urls: (this.options.publicUrls ?? []).map((base) => `${base}/#token=${invite.token}`) };
|
|
99
|
+
});
|
|
100
|
+
app.get('/api/audit', async () => this.audit.recent());
|
|
101
|
+
app.get('/api/devices', async (request) => this.sessions.list().map((device) => ({ ...device, current: device.id === this.access(request).session?.id })));
|
|
102
|
+
app.post('/api/devices/:id/revoke', async (request, reply) => {
|
|
103
|
+
const { id } = idParams.parse(request.params);
|
|
104
|
+
emptyBody.parse(request.body);
|
|
105
|
+
const device = this.sessions.list().find((candidate) => candidate.id === id);
|
|
106
|
+
if (!device || !this.revoke(app, id)) return reply.code(404).send({ error: 'device_not_found' });
|
|
107
|
+
this.audit.record('device_revoked', { source: sourceOf(request), deviceName: device.name });
|
|
108
|
+
return { revoked: true };
|
|
109
|
+
});
|
|
110
|
+
app.post('/api/auth/logout', async (request, reply) => {
|
|
111
|
+
const session = this.access(request).session;
|
|
112
|
+
if (session && this.revoke(app, session.id)) this.audit.record('device_revoked', { source: sourceOf(request), deviceName: session.name });
|
|
113
|
+
reply.header('Set-Cookie', clearedSessionCookie(this.isSecure(request)));
|
|
114
|
+
return { ok: true };
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private isPublic(request: FastifyRequest) {
|
|
119
|
+
const path = request.url.split('?')[0];
|
|
120
|
+
return publicRoutes.some(([method, pattern]) => method === request.method && pattern.test(path));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private isSecure(request: FastifyRequest) { return this.origin.protocol === 'https:' && request.headers.host === this.origin.host; }
|
|
124
|
+
|
|
125
|
+
private grant(request: FastifyRequest, reply: FastifyReply, deviceName: string) {
|
|
126
|
+
reply.header('Set-Cookie', sessionCookie(this.sessions.create(deviceName).token, this.isSecure(request)));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private revoke(app: FastifyInstance, id: string) {
|
|
130
|
+
const revoked = this.sessions.revoke(id);
|
|
131
|
+
for (const socket of app.websocketServer.clients) if (this.deviceBySocket.get(socket) === id) socket.close(1008);
|
|
132
|
+
return revoked;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const styles = `
|
|
2
|
+
:root { color-scheme: light dark; --page: #f8f8f7; --surface: #fff; --ink: #1b1c1d; --soft: #5b5d61; --line: #dedfdd; --blue: #345fba; --danger: #c9534d; }
|
|
3
|
+
@media (prefers-color-scheme: dark) { :root { --page: #121313; --surface: #1a1b1c; --ink: #ececea; --soft: #a2a4a7; --line: #2d2f31; --blue: #5b86e0; --danger: #e0716b; } }
|
|
4
|
+
* { box-sizing: border-box; }
|
|
5
|
+
body { margin: 0; min-height: 100dvh; display: grid; place-items: center; padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); background: var(--page); color: var(--ink); font: 15px/1.5 'DM Sans', system-ui, sans-serif; }
|
|
6
|
+
main { width: min(380px, 100%); padding: 28px 24px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); text-align: center; }
|
|
7
|
+
h1 { margin: 0 0 4px; font-size: 20px; }
|
|
8
|
+
p { margin: 0 0 20px; color: var(--soft); }
|
|
9
|
+
button { width: 100%; min-height: 44px; border: 0; border-radius: 10px; background: var(--blue); color: #fff; font: inherit; font-weight: 600; cursor: pointer; }
|
|
10
|
+
button.link { width: auto; min-height: 0; margin-top: 16px; padding: 4px; background: none; color: var(--soft); font-weight: 400; text-decoration: underline; }
|
|
11
|
+
input { width: 100%; min-height: 44px; margin-bottom: 10px; padding: 0 12px; border: 1px solid var(--line); border-radius: 10px; background: transparent; color: var(--ink); font: 600 16px ui-monospace, Menlo, monospace; letter-spacing: .12em; text-align: center; text-transform: uppercase; }
|
|
12
|
+
.code { margin: 8px 0 16px; font: 700 44px/1 ui-monospace, Menlo, monospace; letter-spacing: .18em; text-indent: .18em; }
|
|
13
|
+
.hint { margin: 0 0 8px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; font-size: 13px; text-align: left; }
|
|
14
|
+
.hint code { font-family: ui-monospace, Menlo, monospace; color: var(--ink); }
|
|
15
|
+
.error { margin-top: 12px; color: var(--danger); }
|
|
16
|
+
[hidden] { display: none !important; }
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
const script = `
|
|
20
|
+
const $ = (id) => document.getElementById(id);
|
|
21
|
+
const views = ['start', 'waiting', 'token-view'];
|
|
22
|
+
const show = (name) => views.forEach((view) => { $(view).hidden = view !== name; });
|
|
23
|
+
const say = (text) => { $('error').textContent = text || ''; };
|
|
24
|
+
const post = (path, body) => fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });
|
|
25
|
+
const enter = () => location.replace('/');
|
|
26
|
+
let timer;
|
|
27
|
+
const stop = () => clearTimeout(timer);
|
|
28
|
+
const reset = (message) => { stop(); sessionStorage.removeItem('webr-pairing'); show('start'); say(message); };
|
|
29
|
+
|
|
30
|
+
async function poll(request) {
|
|
31
|
+
const response = await post('/api/pair/request/' + request.id + '/poll', { secret: request.secret });
|
|
32
|
+
const { status, approverOnline } = await response.json().catch(() => ({ status: 'expired' }));
|
|
33
|
+
if (status === 'approved') return enter();
|
|
34
|
+
if (status === 'denied') return reset('The request was declined.');
|
|
35
|
+
if (status === 'expired') return reset('The request expired. Try again.');
|
|
36
|
+
$('hint').hidden = approverOnline !== false;
|
|
37
|
+
timer = setTimeout(() => poll(request).catch(() => { timer = setTimeout(() => poll(request), 3000); }), 1500);
|
|
38
|
+
}
|
|
39
|
+
function wait(request) { $('code').textContent = request.code; $('hint').hidden = true; show('waiting'); say(''); stop(); poll(request).catch(() => reset('Could not reach the server.')); }
|
|
40
|
+
|
|
41
|
+
async function tooManyRequestsMessage(response) {
|
|
42
|
+
const { error } = await response.json().catch(() => ({}));
|
|
43
|
+
return error === 'cooling_down' ? 'That request was declined. Wait a minute before asking again.' : 'Too many requests. Wait a minute and try again.';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function requestAccess() {
|
|
47
|
+
say('');
|
|
48
|
+
const response = await post('/api/pair/request');
|
|
49
|
+
if (response.status === 429) return say(await tooManyRequestsMessage(response));
|
|
50
|
+
if (!response.ok) return say('Could not send the request.');
|
|
51
|
+
const request = await response.json();
|
|
52
|
+
sessionStorage.setItem('webr-pairing', JSON.stringify(request));
|
|
53
|
+
wait(request);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function redeem(token) {
|
|
57
|
+
const response = await post('/api/pair/redeem', { token });
|
|
58
|
+
if (response.ok) return enter();
|
|
59
|
+
history.replaceState(null, '', location.pathname);
|
|
60
|
+
show('token-view'); say(response.status === 429 ? 'Too many attempts. Wait a minute.' : 'That code is not valid or has expired.');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
$('request').onclick = () => requestAccess().catch(() => say('Could not reach the server.'));
|
|
64
|
+
$('cancel').onclick = () => reset('');
|
|
65
|
+
$('use-token').onclick = () => { say(''); show('token-view'); $('token').focus(); };
|
|
66
|
+
$('back').onclick = () => { say(''); show('start'); };
|
|
67
|
+
$('token-form').onsubmit = (event) => { event.preventDefault(); redeem($('token').value).catch(() => say('Could not reach the server.')); };
|
|
68
|
+
|
|
69
|
+
const hashToken = new URLSearchParams(location.hash.slice(1)).get('token');
|
|
70
|
+
const saved = (() => { try { return JSON.parse(sessionStorage.getItem('webr-pairing') || 'null'); } catch { return null; } })();
|
|
71
|
+
if (hashToken) redeem(hashToken).catch(() => say('Could not reach the server.'));
|
|
72
|
+
else if (saved) wait(saved);
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
const installableAppHead = `<link rel="manifest" href="/manifest.webmanifest"><link rel="icon" href="/favicon.ico" sizes="48x48"><link rel="apple-touch-icon" href="/apple-touch-icon.png"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-title" content="Webr"><meta name="apple-mobile-web-app-status-bar-style" content="default"><meta name="theme-color" media="(prefers-color-scheme: light)" content="#f8f8f7"><meta name="theme-color" media="(prefers-color-scheme: dark)" content="#121313">`;
|
|
76
|
+
|
|
77
|
+
export const pairPage = () => `<!doctype html>
|
|
78
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="robots" content="noindex">${installableAppHead}<title>Connect · Webr</title><style>${styles}</style></head>
|
|
79
|
+
<body><main>
|
|
80
|
+
<h1>Webr</h1>
|
|
81
|
+
<div id="start"><p>This device isn’t connected yet.</p><button id="request" type="button">Request access</button><button id="use-token" class="link" type="button">I have a code</button></div>
|
|
82
|
+
<div id="waiting" hidden><p>Approve this request in Webr on your computer. Make sure the code matches.</p><div class="code" id="code" aria-live="polite"></div><p class="hint" id="hint" hidden>Nobody has Webr open on your computer right now. Open Webr there to approve, or run <code>webr invite</code> in a terminal there and enter the code instead.</p><button id="cancel" class="link" type="button">Cancel</button></div>
|
|
83
|
+
<div id="token-view" hidden><p>Enter the code shown in Webr’s settings.</p><form id="token-form"><input id="token" name="token" autocomplete="one-time-code" autocapitalize="characters" spellcheck="false" placeholder="XXXX-XXXX-XXXX-XXXX" required><button type="submit">Connect</button></form><button id="back" class="link" type="button">Back</button></div>
|
|
84
|
+
<p class="error" id="error" role="alert"></p>
|
|
85
|
+
</main><script>${script}</script></body></html>`;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomInt, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const REQUEST_TTL_MS = 5 * 60_000;
|
|
4
|
+
const INVITE_TTL_MS = 5 * 60_000;
|
|
5
|
+
const MAX_PENDING_REQUESTS = 8;
|
|
6
|
+
const MAX_PENDING_PER_SOURCE = 2;
|
|
7
|
+
const DENY_COOLDOWN_MS = 60_000;
|
|
8
|
+
const TOKEN_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
|
9
|
+
const TOKEN_LENGTH = 16;
|
|
10
|
+
|
|
11
|
+
type PairRequest = { id: string; code: string; deviceName: string; source: string; secretHash: Buffer; expiresAt: number; state: 'pending' | 'approved' | 'denied' };
|
|
12
|
+
export type PendingPairRequest = Pick<PairRequest, 'id' | 'code' | 'deviceName' | 'source' | 'expiresAt'>;
|
|
13
|
+
export type ClaimResult = { status: 'pending' | 'denied' | 'expired' | 'approved' };
|
|
14
|
+
|
|
15
|
+
const sha256 = (value: string) => createHash('sha256').update(value).digest();
|
|
16
|
+
export const normalizeInviteToken = (input: string) => input.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
17
|
+
export const formatInviteToken = (token: string) => token.match(/.{1,4}/g)!.join('-');
|
|
18
|
+
const newInviteToken = () => Array.from({ length: TOKEN_LENGTH }, () => TOKEN_ALPHABET[randomInt(TOKEN_ALPHABET.length)]).join('');
|
|
19
|
+
|
|
20
|
+
export class RateLimiter {
|
|
21
|
+
private readonly hits = new Map<string, number[]>();
|
|
22
|
+
constructor(private readonly limit: number, private readonly windowMs: number, private readonly now = Date.now) {}
|
|
23
|
+
allow(key: string) {
|
|
24
|
+
const at = this.now();
|
|
25
|
+
const recent = (this.hits.get(key) ?? []).filter((time) => at - time < this.windowMs);
|
|
26
|
+
if (recent.length >= this.limit) { this.hits.set(key, recent); return false; }
|
|
27
|
+
this.hits.set(key, [...recent, at]);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class PairingService {
|
|
33
|
+
private readonly requests = new Map<string, PairRequest>();
|
|
34
|
+
private readonly invites = new Map<string, number>();
|
|
35
|
+
private readonly cooldowns = new Map<string, number>();
|
|
36
|
+
constructor(private readonly now = Date.now) {}
|
|
37
|
+
|
|
38
|
+
request(deviceName: string, source: string) {
|
|
39
|
+
this.sweep();
|
|
40
|
+
if (this.cooldowns.has(source)) throw new Error('cooling_down');
|
|
41
|
+
const pending = this.pending();
|
|
42
|
+
if (pending.length >= MAX_PENDING_REQUESTS || pending.filter((request) => request.source === source).length >= MAX_PENDING_PER_SOURCE) throw new Error('too_many_requests');
|
|
43
|
+
const secret = randomBytes(24).toString('base64url');
|
|
44
|
+
const request: PairRequest = { id: randomUUID(), code: String(randomInt(10_000)).padStart(4, '0'), deviceName, source, secretHash: sha256(secret), expiresAt: this.now() + REQUEST_TTL_MS, state: 'pending' };
|
|
45
|
+
this.requests.set(request.id, request);
|
|
46
|
+
return { id: request.id, code: request.code, secret, expiresAt: request.expiresAt };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
pending(): PendingPairRequest[] {
|
|
50
|
+
this.sweep();
|
|
51
|
+
return [...this.requests.values()].filter((request) => request.state === 'pending').map(({ id, code, deviceName, source, expiresAt }) => ({ id, code, deviceName, source, expiresAt }));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
decide(id: string, state: 'approved' | 'denied') {
|
|
55
|
+
this.sweep();
|
|
56
|
+
const request = this.requests.get(id);
|
|
57
|
+
if (!request || request.state !== 'pending') return undefined;
|
|
58
|
+
request.state = state;
|
|
59
|
+
if (state === 'denied') this.cooldowns.set(request.source, this.now() + DENY_COOLDOWN_MS);
|
|
60
|
+
return { deviceName: request.deviceName, source: request.source };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
claim(id: string, secret: string): ClaimResult & { deviceName?: string } {
|
|
64
|
+
this.sweep();
|
|
65
|
+
const request = this.requests.get(id);
|
|
66
|
+
if (!request || !timingSafeEqual(request.secretHash, sha256(secret))) return { status: 'expired' };
|
|
67
|
+
if (request.state === 'pending') return { status: 'pending' };
|
|
68
|
+
this.requests.delete(id);
|
|
69
|
+
return request.state === 'approved' ? { status: 'approved', deviceName: request.deviceName } : { status: 'denied' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
createInvite() {
|
|
73
|
+
this.sweep();
|
|
74
|
+
const token = newInviteToken();
|
|
75
|
+
const expiresAt = this.now() + INVITE_TTL_MS;
|
|
76
|
+
this.invites.set(token, expiresAt);
|
|
77
|
+
return { token, expiresAt };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
redeemInvite(input: string) {
|
|
81
|
+
this.sweep();
|
|
82
|
+
return this.invites.delete(normalizeInviteToken(input));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private sweep() {
|
|
86
|
+
const at = this.now();
|
|
87
|
+
for (const [id, request] of this.requests) if (request.expiresAt <= at) this.requests.delete(id);
|
|
88
|
+
for (const [token, expiresAt] of this.invites) if (expiresAt <= at) this.invites.delete(token);
|
|
89
|
+
for (const [source, until] of this.cooldowns) if (until <= at) this.cooldowns.delete(source);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const ATTENTIVE_WINDOW_MS = 10_000;
|
|
2
|
+
|
|
3
|
+
export class ApproverPresence {
|
|
4
|
+
private lastSeenAt = Number.NEGATIVE_INFINITY;
|
|
5
|
+
constructor(private readonly now = Date.now) {}
|
|
6
|
+
seen() { this.lastSeenAt = this.now(); }
|
|
7
|
+
attentive() { return this.now() - this.lastSeenAt < ATTENTIVE_WINDOW_MS; }
|
|
8
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import type { MetadataDatabase } from '../storage/database';
|
|
3
|
+
import type { AuditLog } from './audit';
|
|
4
|
+
|
|
5
|
+
export type DeviceSession = { id: string; name: string; createdAt: number; lastSeenAt: number };
|
|
6
|
+
|
|
7
|
+
export const SESSION_COOKIE = 'webr_session';
|
|
8
|
+
export const IDLE_EXPIRY_MS = 90 * 24 * 60 * 60 * 1000;
|
|
9
|
+
const COOKIE_MAX_AGE_SECONDS = 400 * 24 * 60 * 60;
|
|
10
|
+
const TOUCH_INTERVAL_MS = 60_000;
|
|
11
|
+
|
|
12
|
+
const hashToken = (token: string) => createHash('sha256').update(token).digest('hex');
|
|
13
|
+
const readCookie = (header: string | undefined, name: string) => header?.split(';').map((part) => part.trim().split('=')).find(([key]) => key === name)?.[1];
|
|
14
|
+
|
|
15
|
+
export const sessionCookie = (token: string, secure: boolean) => `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${COOKIE_MAX_AGE_SECONDS}${secure ? '; Secure' : ''}`;
|
|
16
|
+
export const clearedSessionCookie = (secure: boolean) => `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0${secure ? '; Secure' : ''}`;
|
|
17
|
+
|
|
18
|
+
export class SessionStore {
|
|
19
|
+
constructor(private readonly database: MetadataDatabase, private readonly now = Date.now, private readonly audit?: AuditLog) {}
|
|
20
|
+
|
|
21
|
+
create(name: string) {
|
|
22
|
+
const token = randomBytes(32).toString('base64url');
|
|
23
|
+
const at = this.now();
|
|
24
|
+
const session: DeviceSession = { id: randomUUID(), name, createdAt: at, lastSeenAt: at };
|
|
25
|
+
this.database.addDeviceSession({ ...session, tokenHash: hashToken(token) });
|
|
26
|
+
return { session, token };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
authenticate(cookieHeader: string | undefined, source?: string): DeviceSession | undefined {
|
|
30
|
+
const token = readCookie(cookieHeader, SESSION_COOKIE);
|
|
31
|
+
if (!token) return undefined;
|
|
32
|
+
const row = this.database.deviceSessionByTokenHash(hashToken(token));
|
|
33
|
+
if (!row) return undefined;
|
|
34
|
+
const at = this.now();
|
|
35
|
+
if (at - row.lastSeenAt > IDLE_EXPIRY_MS) { this.database.deleteDeviceSession(row.id); this.audit?.record('session_expired', { source, deviceName: row.name }); return undefined; }
|
|
36
|
+
if (at - row.lastSeenAt > TOUCH_INTERVAL_MS) this.database.touchDeviceSession(row.id, at);
|
|
37
|
+
return { id: row.id, name: row.name, createdAt: row.createdAt, lastSeenAt: at };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
list() { return this.database.deviceSessions().sort((a, b) => b.lastSeenAt - a.lastSeenAt).map((device) => ({ ...device, expiresAt: device.lastSeenAt + IDLE_EXPIRY_MS })); }
|
|
41
|
+
revoke(id: string) { return this.database.deleteDeviceSession(id); }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function deviceNameFrom(userAgent = '') {
|
|
45
|
+
const system = /iPhone/.test(userAgent) ? 'iPhone' : /iPad/.test(userAgent) ? 'iPad' : /Android/.test(userAgent) ? 'Android' : /Macintosh|Mac OS X/.test(userAgent) ? 'Mac' : /Windows/.test(userAgent) ? 'Windows' : /Linux/.test(userAgent) ? 'Linux' : 'Device';
|
|
46
|
+
const browser = /Edg\//.test(userAgent) ? 'Edge' : /Firefox\/|FxiOS/.test(userAgent) ? 'Firefox' : /Chrome\/|CriOS/.test(userAgent) ? 'Chrome' : /Safari\//.test(userAgent) ? 'Safari' : undefined;
|
|
47
|
+
return browser ? `${system} · ${browser}` : system;
|
|
48
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const MAX_SUGGESTIONS = 12;
|
|
6
|
+
|
|
7
|
+
export const expandHome = (path: string) => path === '~' ? homedir() : path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
|
|
8
|
+
|
|
9
|
+
const splitAtLastSlash = (prefix: string) => ({ parent: prefix.slice(0, prefix.lastIndexOf('/') + 1), partial: prefix.slice(prefix.lastIndexOf('/') + 1) });
|
|
10
|
+
const startsWithPartial = (partial: string) => (name: string) => name.toLowerCase().startsWith(partial.toLowerCase()) && (partial.startsWith('.') || !name.startsWith('.'));
|
|
11
|
+
|
|
12
|
+
export async function suggestDirectories(prefix: string): Promise<string[]> {
|
|
13
|
+
if (!prefix.startsWith('/') && !prefix.startsWith('~')) return [];
|
|
14
|
+
const { parent, partial } = splitAtLastSlash(prefix.includes('/') ? prefix : `${prefix}/`);
|
|
15
|
+
const entries = await readdir(expandHome(parent), { withFileTypes: true }).catch(() => []);
|
|
16
|
+
return entries
|
|
17
|
+
.filter((entry) => entry.isDirectory() && startsWithPartial(partial)(entry.name))
|
|
18
|
+
.map((entry) => `${parent}${entry.name}/`)
|
|
19
|
+
.sort((a, b) => a.localeCompare(b))
|
|
20
|
+
.slice(0, MAX_SUGGESTIONS);
|
|
21
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import Fastify, { type FastifyRequest } from 'fastify';
|
|
2
|
+
import websocket from '@fastify/websocket';
|
|
3
|
+
import staticFiles from '@fastify/static';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { distRoot } from './dist';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import type { RuntimeManager } from './runtime/manager';
|
|
8
|
+
import { launchInput, opaqueId } from '../shared/runtime';
|
|
9
|
+
import { registerWebsockets } from './websockets';
|
|
10
|
+
import { createWorkspace } from './runtime/workspace';
|
|
11
|
+
import { expandHome, suggestDirectories } from './directories';
|
|
12
|
+
import { perfLog, perfLogEnabled } from './perf-log';
|
|
13
|
+
import { Auth, type AuthOptions } from './auth/auth';
|
|
14
|
+
import { isTrustedHost } from './auth/access';
|
|
15
|
+
import { MAX_IMAGE_BYTES, UploadStore, uploadContentTypes } from './uploads';
|
|
16
|
+
|
|
17
|
+
const localOwner = 'local';
|
|
18
|
+
type SsrHandler = (request: FastifyRequest['raw'], response: import('node:http').ServerResponse, next: (error?: unknown) => void, locals: Record<string, unknown>) => void;
|
|
19
|
+
|
|
20
|
+
export async function createHost(manager: RuntimeManager, origin: string, ssr?: SsrHandler, tls?: { key: Buffer; cert: Buffer }, uploads = new UploadStore(), authOptions: AuthOptions = {}) {
|
|
21
|
+
const app = Fastify({ logger: false, bodyLimit: 16 * 1024, ...(tls ? { https: tls } : {}), requestTimeout: 10_000 });
|
|
22
|
+
const configuredOrigin = new URL(origin);
|
|
23
|
+
const auth = new Auth(manager.database, configuredOrigin, authOptions);
|
|
24
|
+
app.addHook('onRequest', async (request, reply) => {
|
|
25
|
+
reply.header('X-Content-Type-Options', 'nosniff');
|
|
26
|
+
reply.header('Cache-Control', 'no-store');
|
|
27
|
+
reply.header('Referrer-Policy', 'no-referrer');
|
|
28
|
+
if (!isTrustedHost(request.headers.host, configuredOrigin.host)) return reply.code(403).send({ error: 'invalid_host' });
|
|
29
|
+
const upgrade = request.headers.upgrade === 'websocket';
|
|
30
|
+
if ((request.method !== 'GET' && request.method !== 'HEAD' || upgrade) && request.headers.origin !== auth.expectedOrigin(request)) return reply.code(403).send({ error: 'invalid_origin' });
|
|
31
|
+
return auth.guard(request, reply);
|
|
32
|
+
});
|
|
33
|
+
app.setErrorHandler((error, _request, reply) => {
|
|
34
|
+
const message = error instanceof Error ? error.message : '';
|
|
35
|
+
if (error instanceof z.ZodError) return reply.code(400).send({ error: 'invalid_request' });
|
|
36
|
+
const status = (error as { statusCode?: number }).statusCode;
|
|
37
|
+
if (status === 413) return reply.code(413).send({ error: 'image_too_large' });
|
|
38
|
+
if (status === 415 || message === 'unsupported_image') return reply.code(415).send({ error: 'unsupported_image' });
|
|
39
|
+
if (status && status >= 400 && status < 500) return reply.code(status).send({ error: 'invalid_request' });
|
|
40
|
+
if (message === 'thread_not_found') return reply.code(404).send({ error: message });
|
|
41
|
+
if (message === 'launch_in_progress') return reply.code(409).send({ error: message });
|
|
42
|
+
return reply.code(['idempotency_conflict', 'binding_conflict', 'invalid_adoption'].includes(message) ? 409 : 503).send({ error: ['idempotency_conflict', 'binding_conflict', 'invalid_adoption', 'machine_disconnected'].includes(message) ? message : 'request_failed' });
|
|
43
|
+
});
|
|
44
|
+
await app.register(websocket, { options: { maxPayload: 32 * 1024, perMessageDeflate: false } });
|
|
45
|
+
const hub = registerWebsockets(app, manager);
|
|
46
|
+
auth.routes(app);
|
|
47
|
+
app.get('/api/catalog/machines', async () => manager.bootstrap().machines);
|
|
48
|
+
app.get('/api/catalog/:machineId', async (request) => {
|
|
49
|
+
const { machineId } = z.object({ machineId: opaqueId }).parse(request.params);
|
|
50
|
+
const { projectId } = z.object({ projectId: opaqueId.optional() }).strict().parse(request.query);
|
|
51
|
+
return manager.catalog(machineId, projectId);
|
|
52
|
+
});
|
|
53
|
+
app.get('/api/runtime', async () => manager.bootstrap());
|
|
54
|
+
app.addContentTypeParser(uploadContentTypes, { parseAs: 'buffer', bodyLimit: MAX_IMAGE_BYTES }, (_request, body, done) => done(null, body));
|
|
55
|
+
app.post('/api/uploads', { bodyLimit: MAX_IMAGE_BYTES }, async (request, reply) => {
|
|
56
|
+
const { machineId } = z.object({ machineId: opaqueId }).strict().parse(request.query);
|
|
57
|
+
const supervisor = manager.supervisors.get(machineId);
|
|
58
|
+
if (!supervisor?.connected) return reply.code(409).send({ error: 'machine_disconnected' });
|
|
59
|
+
if (!supervisor.target.acceptsLocalFiles) return reply.code(409).send({ error: 'uploads_unsupported_target' });
|
|
60
|
+
if (!Buffer.isBuffer(request.body)) return reply.code(415).send({ error: 'unsupported_image' });
|
|
61
|
+
return { path: await uploads.save(request.headers['content-type'], request.body) };
|
|
62
|
+
});
|
|
63
|
+
app.get('/api/directories', async (request, reply) => {
|
|
64
|
+
const { machineId, prefix } = z.object({ machineId: opaqueId, prefix: z.string().max(1000).refine((path) => !/[\x00-\x1f]/.test(path)) }).strict().parse(request.query);
|
|
65
|
+
const supervisor = manager.supervisors.get(machineId);
|
|
66
|
+
if (!supervisor?.target.acceptsLocalFiles) return reply.code(409).send({ error: 'directories_unsupported_target' });
|
|
67
|
+
return suggestDirectories(prefix);
|
|
68
|
+
});
|
|
69
|
+
app.get('/api/worktrees', async (request) => {
|
|
70
|
+
const { machineId, projectId } = z.object({ machineId: opaqueId, projectId: opaqueId }).strict().parse(request.query);
|
|
71
|
+
return manager.worktrees(machineId, projectId);
|
|
72
|
+
});
|
|
73
|
+
app.post('/api/worktrees/open', async (request) => {
|
|
74
|
+
const input = z.object({ machineId: opaqueId, projectId: opaqueId, path: z.string().min(1).max(1000) }).strict().parse(request.body);
|
|
75
|
+
return manager.openWorktree(input.machineId, input.projectId, input.path);
|
|
76
|
+
});
|
|
77
|
+
app.post('/api/workspaces', async (request, reply) => {
|
|
78
|
+
const input = z.object({ machineId: opaqueId, path: z.string().regex(/^(\/|~\/)/).max(1000).refine((path) => !/[\x00-\x1f]/.test(path)), label: z.string().trim().min(1).max(80) }).strict().parse(request.body);
|
|
79
|
+
const key = z.uuid().parse(request.headers['idempotency-key']);
|
|
80
|
+
const supervisor = manager.supervisors.get(input.machineId);
|
|
81
|
+
if (!supervisor?.connected) return reply.code(409).send({ error: 'machine_disconnected' });
|
|
82
|
+
const result = await createWorkspace(manager.database, supervisor.target, key, expandHome(input.path), input.label);
|
|
83
|
+
supervisor.invalidate();
|
|
84
|
+
return reply.code(result.state === 'unknown' ? 202 : 201).send(result);
|
|
85
|
+
});
|
|
86
|
+
app.get('/api/projects/:machineId/:projectId/icon', async (request, reply) => {
|
|
87
|
+
const { machineId, projectId } = z.object({ machineId: opaqueId, projectId: opaqueId }).parse(request.params);
|
|
88
|
+
const target = manager.supervisors.get(machineId)?.target;
|
|
89
|
+
const icon = await target?.icon?.(projectId);
|
|
90
|
+
if (!icon) return reply.code(404).send();
|
|
91
|
+
if (icon.contentType === 'image/svg+xml') reply.header('Content-Security-Policy', "sandbox; default-src 'none'; style-src 'unsafe-inline'");
|
|
92
|
+
return reply.type(icon.contentType).send(icon.bytes);
|
|
93
|
+
});
|
|
94
|
+
app.post('/api/threads', async (request, reply) => {
|
|
95
|
+
const input = launchInput.parse(request.body);
|
|
96
|
+
const key = z.uuid().parse(request.headers['idempotency-key']);
|
|
97
|
+
const supervisor = manager.supervisors.get(input.machineId);
|
|
98
|
+
if (!supervisor?.connected) return reply.code(409).send({ error: 'machine_disconnected' });
|
|
99
|
+
const catalog = await manager.catalog(input.machineId, input.projectId);
|
|
100
|
+
if (!catalog.harnesses.find((harness) => harness.id === input.agent)?.launchEnabled || supervisor.target.canLaunch && !supervisor.target.canLaunch(input)) return reply.code(409).send({ error: 'launch_unavailable' });
|
|
101
|
+
const result = manager.journal.submit(localOwner, key, input, supervisor.target);
|
|
102
|
+
return reply.code(202).send(result);
|
|
103
|
+
});
|
|
104
|
+
app.post('/api/threads/:id/adopt', async (request) => {
|
|
105
|
+
const { id } = z.object({ id: z.uuid() }).parse(request.params);
|
|
106
|
+
const input = z.object({ machineId: opaqueId, terminalIds: z.array(opaqueId).min(1).max(256) }).strict().parse(request.body);
|
|
107
|
+
await manager.adopt(input.machineId, id, input.terminalIds); return { adopted: true };
|
|
108
|
+
});
|
|
109
|
+
app.post('/api/threads/:id/focus', async (request) => {
|
|
110
|
+
const { id } = z.object({ id: z.uuid() }).parse(request.params);
|
|
111
|
+
const input = z.object({ machineId: opaqueId, paneId: opaqueId }).strict().parse(request.body);
|
|
112
|
+
await manager.focusPane(input.machineId, id, input.paneId); return { focused: true };
|
|
113
|
+
});
|
|
114
|
+
app.post('/api/threads/:id/archive', async (request) => {
|
|
115
|
+
const { id } = z.object({ id: z.uuid() }).parse(request.params);
|
|
116
|
+
const input = z.object({ machineId: opaqueId, archived: z.boolean() }).strict().parse(request.body);
|
|
117
|
+
manager.archive(input.machineId, id, input.archived); return { archived: input.archived };
|
|
118
|
+
});
|
|
119
|
+
app.post('/api/threads/:id/rename', async (request) => {
|
|
120
|
+
const { id } = z.object({ id: z.uuid() }).parse(request.params);
|
|
121
|
+
const input = z.object({ machineId: opaqueId, title: z.string().trim().min(1).max(90).refine((title) => !/[\x00-\x1f\x7f]/.test(title)) }).strict().parse(request.body);
|
|
122
|
+
await manager.renameThread(input.machineId, id, input.title); return { title: input.title };
|
|
123
|
+
});
|
|
124
|
+
app.post('/api/threads/:id/terminal', async (request) => {
|
|
125
|
+
const { id } = z.object({ id: z.uuid() }).parse(request.params);
|
|
126
|
+
const input = z.object({ machineId: opaqueId, direction: z.enum(['right', 'down']) }).strict().parse(request.body);
|
|
127
|
+
return manager.toggleTerminal(input.machineId, id, input.direction);
|
|
128
|
+
});
|
|
129
|
+
app.post('/api/threads/:id/terminal/close', async (request) => {
|
|
130
|
+
const { id } = z.object({ id: z.uuid() }).parse(request.params);
|
|
131
|
+
const input = z.object({ machineId: opaqueId }).strict().parse(request.body);
|
|
132
|
+
await manager.closeTerminal(input.machineId, id); return { closed: true };
|
|
133
|
+
});
|
|
134
|
+
app.post('/api/threads/:id/delete', async (request) => {
|
|
135
|
+
const { id } = z.object({ id: z.uuid() }).parse(request.params);
|
|
136
|
+
const input = z.object({ machineId: opaqueId }).strict().parse(request.body);
|
|
137
|
+
await manager.deleteThread(input.machineId, id); return { deleted: true };
|
|
138
|
+
});
|
|
139
|
+
app.post('/api/archive/delete', async () => manager.deleteArchived());
|
|
140
|
+
app.get('/api/operations/:id', async (request, reply) => {
|
|
141
|
+
const { id } = z.object({ id: z.uuid() }).parse(request.params);
|
|
142
|
+
const operation = manager.database.operation(id);
|
|
143
|
+
if (!operation || ![localOwner, manager.database.getSetting('allowed_account')].includes(operation.accountId)) return reply.code(404).send({ error: 'operation_not_found' });
|
|
144
|
+
return { id: operation.id, state: operation.state, step: operation.step, threadId: operation.threadId };
|
|
145
|
+
});
|
|
146
|
+
app.get('/api/stats', async () => hub.stats());
|
|
147
|
+
if (perfLogEnabled) app.post('/api/dev/perf', { bodyLimit: 256 * 1024 }, async (request) => { for (const record of z.object({ records: z.array(z.record(z.string(), z.unknown())).max(100) }).parse(request.body).records) perfLog('client', record); return { ok: true }; });
|
|
148
|
+
if (ssr) {
|
|
149
|
+
const clientRoot = resolve(distRoot(), 'client');
|
|
150
|
+
await app.register(staticFiles, { root: clientRoot, serve: false });
|
|
151
|
+
app.get('/_astro/*', async (request, reply) => {
|
|
152
|
+
const path = z.string().regex(/^[A-Za-z0-9_./-]+$/).parse((request.params as { '*': string })['*']);
|
|
153
|
+
const full = resolve(clientRoot, '_astro', path);
|
|
154
|
+
if (!full.startsWith(resolve(clientRoot, '_astro') + '/')) return reply.code(404).send();
|
|
155
|
+
return reply.sendFile(path, resolve(clientRoot, '_astro'));
|
|
156
|
+
});
|
|
157
|
+
app.get('/:file(^[a-z0-9-]+\\.(?:png|ico|webmanifest)$)', async (request, reply) => reply.sendFile((request.params as { file: string }).file, clientRoot));
|
|
158
|
+
app.get('/*', async (request, reply) => {
|
|
159
|
+
reply.hijack();
|
|
160
|
+
if (perfLogEnabled) reply.raw.setHeader('Document-Policy', 'js-profiling');
|
|
161
|
+
ssr(request.raw, reply.raw, () => { if (!reply.raw.headersSent) { reply.raw.statusCode = 404; reply.raw.end('Not found'); } }, { runtime: manager, accountId: localOwner });
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
app.addHook('onClose', async () => manager.close());
|
|
165
|
+
return app;
|
|
166
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { appendFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const perfLogEnabled = process.env.WEBR_DEV === '1';
|
|
6
|
+
const file = process.env.WEBR_PERF_LOG ?? '.data/perf.jsonl';
|
|
7
|
+
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
|
8
|
+
let bytesWritten = 0;
|
|
9
|
+
let ready: Promise<unknown> | undefined;
|
|
10
|
+
|
|
11
|
+
function startWithAnEmptyLog() {
|
|
12
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
13
|
+
writeFileSync(file, '');
|
|
14
|
+
}
|
|
15
|
+
if (perfLogEnabled) startWithAnEmptyLog();
|
|
16
|
+
|
|
17
|
+
export function perfLog(source: 'client' | 'server', record: Record<string, unknown>) {
|
|
18
|
+
if (!perfLogEnabled || bytesWritten >= MAX_LOG_BYTES) return;
|
|
19
|
+
ready ??= mkdir(dirname(file), { recursive: true });
|
|
20
|
+
const line = JSON.stringify({ ...record, at: new Date().toISOString(), source }) + '\n';
|
|
21
|
+
bytesWritten += line.length;
|
|
22
|
+
void ready.then(() => appendFile(file, line)).catch(() => undefined);
|
|
23
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const AGENT_READY_TIMEOUT_MS = 30_000;
|
|
2
|
+
export const SHELL_READY_TIMEOUT_MS = 15_000;
|
|
3
|
+
export const SCREEN_SETTLE_TIMEOUT_MS = 10_000;
|
|
4
|
+
export const TRANSPORT_MARGIN_MS = 5000;
|
|
5
|
+
export const WORKTREE_TIMEOUT_MS = 120_000;
|
|
6
|
+
export const WORKTREE_REMOVE_TIMEOUT_MS = 60_000;
|
|
7
|
+
export const MAX_RPC_TIMEOUT_MS = 305_000;
|
|
8
|
+
export type RpcOptions = { timeoutMs: number; requestId?: string };
|
|
9
|
+
|
|
10
|
+
export function requestDeadline(method: string, params: Record<string, unknown>, readDeadline = 5000, options?: RpcOptions) {
|
|
11
|
+
let timeout = readDeadline;
|
|
12
|
+
if (method === 'agent.start') {
|
|
13
|
+
const ready = params.timeout_ms ?? AGENT_READY_TIMEOUT_MS;
|
|
14
|
+
if (typeof ready !== 'number' || !Number.isInteger(ready) || ready <= 3000 || ready > 300_000) throw new Error('invalid_ready_timeout');
|
|
15
|
+
timeout = ready + TRANSPORT_MARGIN_MS;
|
|
16
|
+
}
|
|
17
|
+
if (method === 'worktree.create') timeout = WORKTREE_TIMEOUT_MS;
|
|
18
|
+
if (method === 'worktree.remove') timeout = WORKTREE_REMOVE_TIMEOUT_MS;
|
|
19
|
+
if (['tab.create', 'workspace.create', 'worktree.open'].includes(method)) timeout = 30_000;
|
|
20
|
+
timeout = options?.timeoutMs ?? timeout;
|
|
21
|
+
const maximum = ['agent.start', 'worktree.create', 'worktree.open', 'worktree.remove', 'tab.create', 'workspace.create'].includes(method) ? MAX_RPC_TIMEOUT_MS : 5000;
|
|
22
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > maximum) throw new Error('invalid_rpc_timeout');
|
|
23
|
+
return timeout;
|
|
24
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
const id = z.string().min(1).max(160);
|
|
4
|
+
const rect = z.object({ x: z.number().int().nonnegative(), y: z.number().int().nonnegative(), width: z.number().int().nonnegative(), height: z.number().int().nonnegative() });
|
|
5
|
+
export const nativePane = z.object({ pane_id: id, terminal_id: id, workspace_id: id, tab_id: id, focused: z.boolean(), revision: z.number().int().nonnegative(), agent_status: z.enum(['idle', 'working', 'blocked', 'done', 'unknown']), agent: z.string().nullable().optional(), title: z.string().nullable().optional(), cwd: z.string().nullable().optional(), terminal_title_stripped: z.string().nullable().optional(), scroll: z.object({ max_offset_from_bottom: z.number().int().nonnegative() }).optional() });
|
|
6
|
+
export const nativeTab = z.object({ tab_id: id, workspace_id: id, label: z.string().nullable().optional() });
|
|
7
|
+
export const nativeWorkspace = z.object({ workspace_id: id, label: z.string(), worktree: z.object({ repo_key: id, repo_name: z.string(), repo_root: z.string(), checkout_path: z.string(), is_linked_worktree: z.boolean() }).nullable().optional() });
|
|
8
|
+
export const nativeSnapshot = z.object({ version: z.string(), protocol: z.literal(22), workspaces: z.array(nativeWorkspace).max(1000), tabs: z.array(nativeTab).max(2000), panes: z.array(nativePane).max(4000), layouts: z.array(z.object({ workspace_id: id, tab_id: id, area: rect, panes: z.array(z.object({ pane_id: id, rect })).max(256) })).max(2000), agents: z.array(z.object({ terminal_id: id, state_change_seq: z.number().int().nonnegative().optional() })).max(4000) });
|
|
9
|
+
export type NativeSnapshot = z.infer<typeof nativeSnapshot>;
|
|
10
|
+
export const lifecycleSubscriptions = ['workspace.created', 'workspace.updated', 'workspace.metadata_updated', 'workspace.closed', 'workspace.renamed', 'workspace.moved', 'workspace.reordered', 'worktree.created', 'worktree.opened', 'worktree.removed', 'tab.created', 'tab.closed', 'tab.renamed', 'tab.moved', 'pane.created', 'pane.closed', 'pane.updated', 'pane.moved', 'pane.exited', 'pane.agent_detected', 'layout.updated'].map((type) => ({ type }));
|