@oddessentials/agent-guild 0.25.0 → 0.27.0
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 +44 -10
- package/bin/agent-guild.mjs +2 -0
- package/node_modules/qrcode-generator/README.md +101 -0
- package/node_modules/qrcode-generator/dist/qrcode.d.ts +57 -0
- package/node_modules/qrcode-generator/dist/qrcode.js +2297 -0
- package/node_modules/qrcode-generator/dist/qrcode.mjs +2237 -0
- package/node_modules/qrcode-generator/dist/qrcode_SJIS.js +30 -0
- package/node_modules/qrcode-generator/dist/qrcode_SJIS.mjs +23 -0
- package/node_modules/qrcode-generator/dist/qrcode_UTF8.js +26 -0
- package/node_modules/qrcode-generator/dist/qrcode_UTF8.mjs +50 -0
- package/node_modules/qrcode-generator/experiment/vt/package-lock.json +6184 -0
- package/node_modules/qrcode-generator/experiment/vt/package.json +19 -0
- package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode.ts +2311 -0
- package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode_SJIS.ts +25 -0
- package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode_UTF8.ts +50 -0
- package/node_modules/qrcode-generator/experiment/vt/src/main/types.ts +80 -0
- package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode-test-impl.js +353 -0
- package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode.test.mjs +5 -0
- package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode_SJIS.test.mjs +10 -0
- package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode_UTF8.test.mjs +9 -0
- package/node_modules/qrcode-generator/experiment/vt/tsconfig.json +25 -0
- package/node_modules/qrcode-generator/experiment/vt/vite.config.js +31 -0
- package/node_modules/qrcode-generator/experiment/vt/web-test-runner.config.mjs +7 -0
- package/node_modules/qrcode-generator/package.json +37 -0
- package/node_modules/qrcode-generator/test/qrcode-test-impl.js +353 -0
- package/node_modules/qrcode-generator/test/qrcode.test.html +18 -0
- package/node_modules/qrcode-generator/test/qrcode.test.mjs +5 -0
- package/node_modules/qrcode-generator/test/qrcode_SJIS.test.html +21 -0
- package/node_modules/qrcode-generator/test/qrcode_SJIS.test.mjs +10 -0
- package/node_modules/qrcode-generator/test/qrcode_UTF8.test.html +19 -0
- package/node_modules/qrcode-generator/test/qrcode_UTF8.test.mjs +9 -0
- package/node_modules/qrcode-generator/web-test-runner.config.mjs +8 -0
- package/package.json +3 -2
- package/src/manager/access-policy.mjs +39 -0
- package/src/manager/config.mjs +1 -0
- package/src/manager/folder-opener.mjs +96 -0
- package/src/manager/folder-picker.mjs +116 -0
- package/src/manager/folder-picker.ps1 +84 -0
- package/src/manager/github-views.mjs +171 -0
- package/src/manager/github.mjs +56 -1
- package/src/manager/main.mjs +11 -6
- package/src/manager/remote-access.mjs +286 -0
- package/src/manager/server.mjs +105 -18
- package/src/manager/tailscale.mjs +201 -0
- package/web/activity-favicon.js +71 -0
- package/web/app.js +1154 -98
- package/web/index.html +205 -92
- package/web/layout.js +75 -0
- package/web/remote-access.js +236 -0
- package/web/repo-search.js +91 -0
- package/web/skins/gnomeland/skin.css +1 -1
- package/web/skins/goblinville/skin.css +2 -2
- package/web/skins/grove/skin.css +1 -1
- package/web/skins/guild/skin.css +3 -3
- package/web/skins/orbital/skin.css +1 -1
- package/web/styles.css +269 -38
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import https from 'node:https';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
import { normalizeAccess, parseAllowedHosts, parseAllowedOrigins } from './access-policy.mjs';
|
|
7
|
+
import { Tailscale, chooseRoute, remoteError, routeMatches, routeUrl } from './tailscale.mjs';
|
|
8
|
+
|
|
9
|
+
const emptyAccess = () => ({ hosts: [], origins: [] });
|
|
10
|
+
|
|
11
|
+
function validateRoute(route) {
|
|
12
|
+
if (!route || typeof route.nodeId !== 'string' || !route.nodeId || route.nodeId.length > 256
|
|
13
|
+
|| typeof route.host !== 'string' || !route.host.endsWith('.ts.net') || parseAllowedHosts(route.host)[0] !== route.host
|
|
14
|
+
|| !Number.isInteger(route.port) || route.port < 1 || route.port > 65535
|
|
15
|
+
|| typeof route.target !== 'string' || !/^http:\/\/127\.0\.0\.1:[1-9]\d{0,4}$/.test(route.target)
|
|
16
|
+
|| Number(new URL(route.target).port) > 65535) throw new Error('Invalid saved Tailscale route.');
|
|
17
|
+
return { nodeId: route.nodeId, host: route.host, port: route.port, target: route.target };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function routeAccess(route) {
|
|
21
|
+
const url = new URL(routeUrl(route));
|
|
22
|
+
return { hosts: [url.host], origins: [url.origin] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function validateSaved(value) {
|
|
26
|
+
if (!value || value.version !== 1 || typeof value.revision !== 'string' || value.revision.length > 100
|
|
27
|
+
|| !['off', 'custom', 'tailscale'].includes(value.mode)) throw new Error('Unsupported remote-access settings.');
|
|
28
|
+
const route = value.route ? validateRoute(value.route) : null;
|
|
29
|
+
if (value.mode === 'tailscale' && !route) throw new Error('Missing saved Tailscale route.');
|
|
30
|
+
if (value.mode !== 'tailscale' && route) throw new Error('Unexpected saved Tailscale route.');
|
|
31
|
+
const access = value.mode === 'off' ? emptyAccess() : value.mode === 'tailscale' ? routeAccess(route) : normalizeAccess(value.access);
|
|
32
|
+
let pending = null;
|
|
33
|
+
if (value.pending) {
|
|
34
|
+
if (!['enable', 'disable'].includes(value.pending.type)) throw new Error('Invalid pending remote-access operation.');
|
|
35
|
+
pending = { type: value.pending.type, route: validateRoute(value.pending.route), ...(value.pending.previous ? { previous: validateRoute(value.pending.previous) } : {}) };
|
|
36
|
+
}
|
|
37
|
+
return { version: 1, revision: value.revision, mode: value.mode, access, route, pending };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function loadRemoteAccess(file, env = process.env) {
|
|
41
|
+
let raw;
|
|
42
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch (error) {
|
|
43
|
+
if (error.code !== 'ENOENT') return { config: disabledConfig(), source: 'saved', error: 'Remote-access settings could not be read. Local access remains available.' };
|
|
44
|
+
}
|
|
45
|
+
if (raw !== undefined) {
|
|
46
|
+
try {
|
|
47
|
+
if (raw.length > 65536) throw new Error('Settings are too large.');
|
|
48
|
+
return { config: validateSaved(JSON.parse(raw)), source: 'saved', error: null };
|
|
49
|
+
} catch {
|
|
50
|
+
return { config: disabledConfig(), source: 'saved', error: 'Saved remote-access settings are invalid. Set up remote access again to replace them. Local access remains available.' };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const access = { hosts: parseAllowedHosts(env.AGENT_GUILD_ALLOWED_HOSTS), origins: parseAllowedOrigins(env.AGENT_GUILD_ALLOWED_ORIGINS) };
|
|
54
|
+
const enabled = access.hosts.length > 0 || access.origins.length > 0;
|
|
55
|
+
return { config: { ...disabledConfig(), mode: enabled ? 'custom' : 'off', access }, source: enabled ? 'environment' : 'default', error: null };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function disabledConfig() {
|
|
59
|
+
return { version: 1, revision: randomUUID(), mode: 'off', access: emptyAccess(), route: null, pending: null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function saveRemoteAccess(file, config) {
|
|
63
|
+
const value = validateSaved(config);
|
|
64
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
65
|
+
const next = `${file}.${randomUUID()}.tmp`;
|
|
66
|
+
let descriptor;
|
|
67
|
+
try {
|
|
68
|
+
descriptor = fs.openSync(next, 'wx', 0o600);
|
|
69
|
+
fs.writeFileSync(descriptor, JSON.stringify(value, null, 2) + '\n');
|
|
70
|
+
fs.fsyncSync(descriptor);
|
|
71
|
+
fs.closeSync(descriptor);
|
|
72
|
+
descriptor = undefined;
|
|
73
|
+
fs.renameSync(next, file);
|
|
74
|
+
} finally {
|
|
75
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
76
|
+
try { fs.unlinkSync(next); } catch {}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function checkRemoteUrl(url, { signal, pid = process.pid } = {}) {
|
|
81
|
+
return new Promise((resolve) => {
|
|
82
|
+
let done = false;
|
|
83
|
+
const finish = (ok) => { if (!done) { done = true; clearTimeout(timer); resolve({ ok, checkedAt: new Date().toISOString() }); } };
|
|
84
|
+
const timer = setTimeout(() => { request.destroy(); finish(false); }, 7000);
|
|
85
|
+
const request = https.get(`${url}/api/v1/health`, { signal, headers: { 'Cache-Control': 'no-store' } }, (response) => {
|
|
86
|
+
let body = '';
|
|
87
|
+
response.setEncoding('utf8');
|
|
88
|
+
response.on('data', (chunk) => {
|
|
89
|
+
body += chunk;
|
|
90
|
+
if (body.length > 16384) { request.destroy(); finish(false); }
|
|
91
|
+
});
|
|
92
|
+
response.on('end', () => {
|
|
93
|
+
try {
|
|
94
|
+
const health = JSON.parse(body);
|
|
95
|
+
finish(response.statusCode === 200 && health.name === 'agent-guild' && health.pid === pid);
|
|
96
|
+
} catch { finish(false); }
|
|
97
|
+
});
|
|
98
|
+
response.on('error', () => finish(false));
|
|
99
|
+
});
|
|
100
|
+
request.on('error', () => finish(false));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class RemoteAccess extends EventEmitter {
|
|
105
|
+
constructor({ file, env = process.env, loaded = loadRemoteAccess(file, env), tailscale = new Tailscale({ env }), save = saveRemoteAccess, probe = checkRemoteUrl } = {}) {
|
|
106
|
+
super();
|
|
107
|
+
Object.assign(this, { file, tailscale, save, probe, config: loaded.config, source: loaded.source });
|
|
108
|
+
this.problem = loaded.error ? { code: 'invalid_settings', message: loaded.error } : null;
|
|
109
|
+
this.busy = null;
|
|
110
|
+
this.info = null;
|
|
111
|
+
this.connection = null;
|
|
112
|
+
this.closed = false;
|
|
113
|
+
this.abort = new AbortController();
|
|
114
|
+
this.apply = () => {};
|
|
115
|
+
this.getTarget = () => null;
|
|
116
|
+
this.task = Promise.resolve();
|
|
117
|
+
this.checkTask = null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
attach({ apply, getTarget }) {
|
|
121
|
+
this.apply = apply;
|
|
122
|
+
this.getTarget = getTarget;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
snapshot() {
|
|
126
|
+
const config = this.config;
|
|
127
|
+
let candidate = null;
|
|
128
|
+
if (this.info && !this.busy && this.getTarget()) {
|
|
129
|
+
try {
|
|
130
|
+
const route = chooseRoute(this.info, this.getTarget(), config.pending?.type === 'enable' ? config.pending.route : config.route);
|
|
131
|
+
candidate = { url: routeUrl(route), existing: !config.route && !config.pending && routeMatches(this.info.config, route) };
|
|
132
|
+
} catch {}
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
available: true, revision: config.revision, source: this.source, mode: config.mode,
|
|
136
|
+
hosts: [...config.access.hosts], origins: [...config.access.origins],
|
|
137
|
+
url: config.route ? routeUrl(config.route) : null,
|
|
138
|
+
busy: this.busy, pending: config.pending?.type || null, problem: this.problem,
|
|
139
|
+
tailscale: this.info ? { connected: true, version: this.info.version, hostname: this.info.host, httpsReady: this.info.httpsReady } : null,
|
|
140
|
+
command: config.pending ? `tailscale serve --bg --https=${config.pending.route.port} --set-path=/ ${config.pending.type === 'disable' ? 'off' : config.pending.route.target}` : null,
|
|
141
|
+
candidate, connection: this.connection,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
publish() { if (!this.closed) this.emit('updated'); }
|
|
146
|
+
|
|
147
|
+
persist(next) {
|
|
148
|
+
if (this.closed) throw remoteError('manager_stopping', 'The manager is stopping.');
|
|
149
|
+
const value = validateSaved({ ...next, version: 1, revision: randomUUID() });
|
|
150
|
+
this.save(this.file, value);
|
|
151
|
+
this.config = value;
|
|
152
|
+
this.source = 'saved';
|
|
153
|
+
this.apply(value.access);
|
|
154
|
+
this.publish();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async check() {
|
|
158
|
+
if (this.closed) return this.snapshot();
|
|
159
|
+
if (this.checkTask) return this.checkTask;
|
|
160
|
+
if (this.busy) return this.snapshot();
|
|
161
|
+
this.busy = 'checking';
|
|
162
|
+
this.publish();
|
|
163
|
+
this.checkTask = this.inspect().finally(() => {
|
|
164
|
+
this.busy = null;
|
|
165
|
+
this.checkTask = null;
|
|
166
|
+
this.publish();
|
|
167
|
+
}).then(() => this.snapshot());
|
|
168
|
+
return this.checkTask;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async inspect() {
|
|
172
|
+
this.connection = null;
|
|
173
|
+
try {
|
|
174
|
+
this.info = await this.tailscale.inspect({ signal: this.abort.signal });
|
|
175
|
+
const config = this.config;
|
|
176
|
+
if (config.pending) {
|
|
177
|
+
try { chooseRoute(this.info, config.pending.route.target, config.pending.route); } catch (error) {
|
|
178
|
+
const previous = config.pending.previous || config.route;
|
|
179
|
+
if (!previous || !routeMatches(this.info.config, previous)) throw error;
|
|
180
|
+
chooseRoute(this.info, previous.target, previous);
|
|
181
|
+
}
|
|
182
|
+
this.problem = this.problem?.approvalUrl && config.pending.type === 'enable' ? this.problem : { code: 'operation_pending', message: config.pending.type === 'enable'
|
|
183
|
+
? 'Setup is unfinished. Continue to check and complete it.'
|
|
184
|
+
: 'Access through the saved address is blocked. The Tailscale route still needs cleanup.' };
|
|
185
|
+
} else if (config.route) {
|
|
186
|
+
chooseRoute(this.info, this.getTarget(), config.route);
|
|
187
|
+
if (!routeMatches(this.info.config, config.route) || config.route.target !== this.getTarget()) {
|
|
188
|
+
throw remoteError('repair_required', 'The Tailscale route needs to be updated for this manager.');
|
|
189
|
+
}
|
|
190
|
+
this.problem = null;
|
|
191
|
+
this.connection = await this.probe(routeUrl(config.route), { signal: this.abort.signal });
|
|
192
|
+
} else if (this.problem?.code !== 'invalid_settings') this.problem = null;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
this.info = null;
|
|
195
|
+
this.problem = { code: error.code || 'tailscale_error', message: error.message, ...(error.approvalUrl ? { approvalUrl: error.approvalUrl } : {}) };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
change(body) {
|
|
200
|
+
if (this.closed) throw remoteError('manager_stopping', 'The manager is stopping.');
|
|
201
|
+
if (this.busy) throw remoteError('remote_busy', 'A remote-access operation is already in progress.');
|
|
202
|
+
if (body.revision !== this.config.revision) throw remoteError('stale_settings', 'Remote access changed in another tab. Review the current settings and try again.');
|
|
203
|
+
if (!['enable', 'disable', 'custom', 'forget'].includes(body.action)) throw remoteError('bad_action', 'Choose a valid remote-access action.', 400);
|
|
204
|
+
let access;
|
|
205
|
+
if (body.action === 'custom') {
|
|
206
|
+
if (this.config.route || this.config.pending) throw remoteError('disable_first', 'Disable the Tailscale connection before changing custom proxy settings.');
|
|
207
|
+
try { access = normalizeAccess({ hosts: body.hosts, origins: body.origins }); } catch (error) { throw remoteError('bad_settings', error.message, 400); }
|
|
208
|
+
}
|
|
209
|
+
if (body.action === 'forget' && (this.config.mode !== 'off' || this.config.pending?.type !== 'disable')) {
|
|
210
|
+
throw remoteError('bad_action', 'Only an inactive route awaiting cleanup can be left in Tailscale.', 400);
|
|
211
|
+
}
|
|
212
|
+
this.busy = body.action === 'enable' ? 'enabling' : body.action === 'disable' ? 'disabling' : 'saving';
|
|
213
|
+
this.problem = null;
|
|
214
|
+
this.connection = null;
|
|
215
|
+
this.publish();
|
|
216
|
+
this.task = Promise.resolve().then(async () => {
|
|
217
|
+
if (body.action === 'enable') await this.enable(body.adopt === true);
|
|
218
|
+
else if (body.action === 'disable') await this.disable();
|
|
219
|
+
else if (body.action === 'custom') this.persist({ ...this.config, mode: access.hosts.length || access.origins.length ? 'custom' : 'off', access, route: null, pending: null });
|
|
220
|
+
else this.persist({ ...this.config, pending: null });
|
|
221
|
+
}).catch((error) => {
|
|
222
|
+
this.problem = { code: error.code || 'save_failed', message: error.message, ...(error.approvalUrl ? { approvalUrl: error.approvalUrl } : {}) };
|
|
223
|
+
}).finally(() => {
|
|
224
|
+
this.busy = null;
|
|
225
|
+
this.publish();
|
|
226
|
+
});
|
|
227
|
+
return this.snapshot();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async enable(adopt) {
|
|
231
|
+
if (this.config.pending?.type === 'disable') throw remoteError('cleanup_required', 'Finish cleanup of the previous route before enabling remote access.');
|
|
232
|
+
const info = await this.tailscale.inspect({ signal: this.abort.signal });
|
|
233
|
+
this.info = info;
|
|
234
|
+
let previous = this.config.pending?.type === 'enable' ? this.config.pending.route : this.config.route;
|
|
235
|
+
let route;
|
|
236
|
+
try { route = chooseRoute(info, this.getTarget(), previous); } catch (error) {
|
|
237
|
+
const fallback = this.config.pending?.previous || this.config.route;
|
|
238
|
+
if (!fallback || !routeMatches(info.config, fallback)) throw error;
|
|
239
|
+
previous = fallback;
|
|
240
|
+
route = chooseRoute(info, this.getTarget(), previous);
|
|
241
|
+
}
|
|
242
|
+
if (!previous && routeMatches(info.config, route) && !adopt) {
|
|
243
|
+
throw remoteError('adoption_required', 'A matching Tailscale route already exists. Choose “Use existing route” to let Agent Guild manage it.');
|
|
244
|
+
}
|
|
245
|
+
this.persist({ ...this.config, pending: { type: 'enable', route, ...(previous ? { previous } : {}) } });
|
|
246
|
+
let approvalUrl = null;
|
|
247
|
+
await this.tailscale.enable(route, previous, {
|
|
248
|
+
signal: this.abort.signal,
|
|
249
|
+
onApproval: (url) => {
|
|
250
|
+
approvalUrl = url;
|
|
251
|
+
this.problem = { code: 'approval_required', message: 'Approve HTTPS in Tailscale to continue setup.', approvalUrl: url };
|
|
252
|
+
this.publish();
|
|
253
|
+
},
|
|
254
|
+
}).catch((error) => {
|
|
255
|
+
if (approvalUrl && error.code === 'setup_incomplete') error.approvalUrl = approvalUrl;
|
|
256
|
+
throw error;
|
|
257
|
+
});
|
|
258
|
+
this.persist({ ...this.config, mode: 'tailscale', access: routeAccess(route), route, pending: null });
|
|
259
|
+
this.problem = null;
|
|
260
|
+
this.info = await this.tailscale.inspect({ signal: this.abort.signal });
|
|
261
|
+
this.connection = await this.probe(routeUrl(route), { signal: this.abort.signal });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async disable() {
|
|
265
|
+
const route = this.config.pending?.route || this.config.route;
|
|
266
|
+
const previous = this.config.pending?.previous || this.config.route;
|
|
267
|
+
this.persist({ ...this.config, mode: 'off', access: emptyAccess(), route: null, pending: route ? { type: 'disable', route, ...(previous ? { previous } : {}) } : null });
|
|
268
|
+
if (route) {
|
|
269
|
+
try {
|
|
270
|
+
const info = await this.tailscale.inspect({ signal: this.abort.signal });
|
|
271
|
+
const cleanup = previous && info.nodeId === previous.nodeId && info.host === previous.host && routeMatches(info.config, previous) ? previous : route;
|
|
272
|
+
this.info = await this.tailscale.remove(cleanup, { signal: this.abort.signal });
|
|
273
|
+
} catch (error) {
|
|
274
|
+
error.message = `Access through the saved address is blocked. ${error.message}`;
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
this.persist({ ...this.config, pending: null });
|
|
278
|
+
}
|
|
279
|
+
this.problem = null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
close() {
|
|
283
|
+
this.closed = true;
|
|
284
|
+
this.abort.abort();
|
|
285
|
+
}
|
|
286
|
+
}
|
package/src/manager/server.mjs
CHANGED
|
@@ -7,6 +7,11 @@ import path from 'node:path';
|
|
|
7
7
|
import { createRequire } from 'node:module';
|
|
8
8
|
import { WebSocketServer } from 'ws';
|
|
9
9
|
import { timingSafeEqualString } from './session-manager.mjs';
|
|
10
|
+
import { folderOrigin } from './github.mjs';
|
|
11
|
+
import { createViews } from './github-views.mjs';
|
|
12
|
+
import { createFolderOpener } from './folder-opener.mjs';
|
|
13
|
+
import { createFolderPicker } from './folder-picker.mjs';
|
|
14
|
+
import { normalizeAccess } from './access-policy.mjs';
|
|
10
15
|
|
|
11
16
|
const require = createRequire(import.meta.url);
|
|
12
17
|
const API = '/api/v1';
|
|
@@ -36,6 +41,7 @@ function vendorFiles() {
|
|
|
36
41
|
'/vendor/xterm/xterm.css': path.join(pkgDir('@xterm/xterm'), 'css/xterm.css'),
|
|
37
42
|
'/vendor/xterm/addon-fit.js': path.join(pkgDir('@xterm/addon-fit'), 'lib/addon-fit.js'),
|
|
38
43
|
'/vendor/xterm/addon-web-links.js': path.join(pkgDir('@xterm/addon-web-links'), 'lib/addon-web-links.js'),
|
|
44
|
+
'/vendor/qrcode.mjs': path.join(path.dirname(require.resolve('qrcode-generator')), 'qrcode.mjs'),
|
|
39
45
|
};
|
|
40
46
|
}
|
|
41
47
|
|
|
@@ -57,17 +63,6 @@ class HttpError extends Error {
|
|
|
57
63
|
}
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
function sendJson(res, status, body) {
|
|
61
|
-
const payload = JSON.stringify(body);
|
|
62
|
-
res.writeHead(status, {
|
|
63
|
-
...SECURITY_HEADERS,
|
|
64
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
65
|
-
'Cache-Control': 'no-store',
|
|
66
|
-
'Content-Length': Buffer.byteLength(payload),
|
|
67
|
-
});
|
|
68
|
-
res.end(payload);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
66
|
function readJsonBody(req) {
|
|
72
67
|
return new Promise((resolve, reject) => {
|
|
73
68
|
let size = 0;
|
|
@@ -115,20 +110,32 @@ export function createManagerServer({
|
|
|
115
110
|
webDir,
|
|
116
111
|
version = '0.0.0',
|
|
117
112
|
selfUpdate = null,
|
|
113
|
+
extraHosts = [],
|
|
118
114
|
extraOrigins = [],
|
|
115
|
+
remoteAccess = null,
|
|
116
|
+
folderOpener = createFolderOpener({ resolveCwd: (cwd) => manager.resolveCwd(cwd) }),
|
|
117
|
+
folderPicker = createFolderPicker({ resolveCwd: (cwd) => manager.resolveCwd(cwd) }),
|
|
119
118
|
/** The double-click launcher file for this platform, or null when the package carries none. */
|
|
120
119
|
launcher = null,
|
|
121
120
|
/** @type {(opts: { restart: boolean }) => void} */
|
|
122
121
|
onShutdownRequest = () => {},
|
|
123
122
|
}) {
|
|
123
|
+
let access = normalizeAccess({ hosts: extraHosts, origins: extraOrigins });
|
|
124
|
+
let policyVersion = 0;
|
|
125
|
+
const headersForAccess = () => ({
|
|
126
|
+
...SECURITY_HEADERS,
|
|
127
|
+
'Content-Security-Policy': SECURITY_HEADERS['Content-Security-Policy'].replace(
|
|
128
|
+
"connect-src 'self'", ["connect-src 'self'", ...access.hosts.flatMap((authority) => [`ws://${authority}`, `wss://${authority}`])].join(' ')),
|
|
129
|
+
});
|
|
130
|
+
let securityHeaders = headersForAccess();
|
|
124
131
|
const upgradeInfo = () => (selfUpdate ? selfUpdate.describe() : null);
|
|
125
132
|
const startedAt = new Date().toISOString();
|
|
126
133
|
const vendor = vendorFiles();
|
|
127
134
|
let boundPort = port;
|
|
128
135
|
|
|
129
|
-
const allowedHosts = () => new Set([`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}
|
|
136
|
+
const allowedHosts = () => new Set([`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`, ...access.hosts]);
|
|
130
137
|
const allowedOrigins = () =>
|
|
131
|
-
new Set([`http://127.0.0.1:${boundPort}`, `http://localhost:${boundPort}`, `http://[::1]:${boundPort}`, ...
|
|
138
|
+
new Set([`http://127.0.0.1:${boundPort}`, `http://localhost:${boundPort}`, `http://[::1]:${boundPort}`, ...access.origins]);
|
|
132
139
|
|
|
133
140
|
/** Blocks DNS-rebinding (Host) and cross-site browser requests (Origin). */
|
|
134
141
|
function checkRequestSource(req) {
|
|
@@ -154,13 +161,24 @@ export function createManagerServer({
|
|
|
154
161
|
}
|
|
155
162
|
}
|
|
156
163
|
|
|
164
|
+
function sendJson(res, status, body) {
|
|
165
|
+
const payload = JSON.stringify(body);
|
|
166
|
+
res.writeHead(status, {
|
|
167
|
+
...securityHeaders,
|
|
168
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
169
|
+
'Cache-Control': 'no-store',
|
|
170
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
171
|
+
});
|
|
172
|
+
res.end(payload);
|
|
173
|
+
}
|
|
174
|
+
|
|
157
175
|
function serveFile(req, res, file, { cache = false } = {}) {
|
|
158
176
|
const notFound = () => sendJson(res, 404, { error: { code: 'not_found', message: 'not found' } });
|
|
159
177
|
fs.stat(file, (statErr, stat) => {
|
|
160
178
|
if (statErr || !stat.isFile()) return notFound();
|
|
161
|
-
const etag = `W/"${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`;
|
|
179
|
+
const etag = `W/"${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}-${policyVersion}"`;
|
|
162
180
|
const headers = {
|
|
163
|
-
...
|
|
181
|
+
...securityHeaders,
|
|
164
182
|
'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream',
|
|
165
183
|
'Cache-Control': cache ? 'public, max-age=3600' : 'no-cache',
|
|
166
184
|
ETag: etag,
|
|
@@ -228,6 +246,17 @@ export function createManagerServer({
|
|
|
228
246
|
|
|
229
247
|
requireAuth(req, url);
|
|
230
248
|
|
|
249
|
+
if (remoteAccess && route === '/remote-access' && method === 'GET') {
|
|
250
|
+
return sendJson(res, 200, { remoteAccess: remoteAccess.snapshot() });
|
|
251
|
+
}
|
|
252
|
+
if (remoteAccess && route === '/remote-access/check' && method === 'POST') {
|
|
253
|
+
return sendJson(res, 200, { remoteAccess: await remoteAccess.check() });
|
|
254
|
+
}
|
|
255
|
+
if (remoteAccess && route === '/remote-access' && method === 'PUT') {
|
|
256
|
+
const operation = remoteAccess.change(await readJsonBody(req));
|
|
257
|
+
return sendJson(res, 202, { remoteAccess: operation });
|
|
258
|
+
}
|
|
259
|
+
|
|
231
260
|
if (route === '/info' && method === 'GET') {
|
|
232
261
|
selfUpdate?.refresh().catch(() => {});
|
|
233
262
|
return sendJson(res, 200, {
|
|
@@ -239,8 +268,22 @@ export function createManagerServer({
|
|
|
239
268
|
warnings: registry.warnings,
|
|
240
269
|
upgrade: upgradeInfo(),
|
|
241
270
|
launcher,
|
|
271
|
+
folderOpener: folderOpener.describe(),
|
|
272
|
+
folderPicker: folderPicker.describe(),
|
|
273
|
+
remoteAccess: remoteAccess ? { available: true } : null,
|
|
242
274
|
});
|
|
243
275
|
}
|
|
276
|
+
if (route === '/open-folder' && method === 'POST') {
|
|
277
|
+
const { cwd } = await readJsonBody(req);
|
|
278
|
+
await folderOpener.open(cwd);
|
|
279
|
+
return sendJson(res, 200, { ok: true });
|
|
280
|
+
}
|
|
281
|
+
if (route === '/pick-folder' && method === 'POST') {
|
|
282
|
+
const { cwd } = await readJsonBody(req);
|
|
283
|
+
const gone = new AbortController();
|
|
284
|
+
res.once('close', () => gone.abort());
|
|
285
|
+
return sendJson(res, 200, { path: await folderPicker.pick(cwd, { signal: gone.signal }) });
|
|
286
|
+
}
|
|
244
287
|
if (route === '/upgrade' && method === 'POST') {
|
|
245
288
|
const session = await manager.upgrade();
|
|
246
289
|
return sendJson(res, 201, { session: session.toJSON() });
|
|
@@ -362,6 +405,8 @@ export function createManagerServer({
|
|
|
362
405
|
throw new HttpError(404, `no route for ${method} ${url.pathname}`, 'not_found');
|
|
363
406
|
}
|
|
364
407
|
|
|
408
|
+
const views = github ? createViews(github) : null;
|
|
409
|
+
|
|
365
410
|
async function handleGitHub(req, res, url, route, method) {
|
|
366
411
|
const snapshot = () => ({ github: github.snapshot() });
|
|
367
412
|
if (route === '/github' && method === 'GET') return sendJson(res, 200, snapshot());
|
|
@@ -378,6 +423,30 @@ export function createManagerServer({
|
|
|
378
423
|
const session = manager.clone({ account: body.account, repo: body.repo, parent: body.parent });
|
|
379
424
|
return sendJson(res, 201, { session: session.toJSON() });
|
|
380
425
|
}
|
|
426
|
+
if (route === '/github/repos' && method === 'GET') {
|
|
427
|
+
return sendJson(res, 200, await github.allRepos({ refresh: url.searchParams.get('refresh') === '1' }));
|
|
428
|
+
}
|
|
429
|
+
if (route === '/github/origin' && method === 'GET') {
|
|
430
|
+
const folder = manager.resolveCwd(url.searchParams.get('cwd'));
|
|
431
|
+
return sendJson(res, 200, { folder, repo: folderOrigin(folder) });
|
|
432
|
+
}
|
|
433
|
+
const view = route.match(/^\/github\/accounts\/([^/]+)\/repos\/([^/]+)\/([^/]+)\/(issues|actions|pulls)(?:\/([^/]+))?$/);
|
|
434
|
+
if (view) {
|
|
435
|
+
let parts;
|
|
436
|
+
try { parts = view.map((part) => (part === undefined ? part : decodeURIComponent(part))); } catch { throw new HttpError(400, 'repo must be a GitHub repository written as owner/name', 'bad_repo'); }
|
|
437
|
+
const [, id, owner, name, kind, number] = parts;
|
|
438
|
+
if (kind === 'issues' && number === undefined && method === 'GET') {
|
|
439
|
+
return sendJson(res, 200, await views.issues(id, owner, name, { state: url.searchParams.get('state') || 'open' }));
|
|
440
|
+
}
|
|
441
|
+
if (kind === 'issues' && number === undefined && method === 'POST') {
|
|
442
|
+
return sendJson(res, 201, { issue: await views.createIssue(id, owner, name, await readJsonBody(req)) });
|
|
443
|
+
}
|
|
444
|
+
if (kind === 'issues' && number !== undefined && method === 'PATCH') {
|
|
445
|
+
return sendJson(res, 200, { issue: await views.updateIssue(id, owner, name, number, await readJsonBody(req)) });
|
|
446
|
+
}
|
|
447
|
+
if (kind === 'actions' && number === undefined && method === 'GET') return sendJson(res, 200, await views.actions(id, owner, name));
|
|
448
|
+
if (kind === 'pulls' && number === undefined && method === 'GET') return sendJson(res, 200, await views.pulls(id, owner, name));
|
|
449
|
+
}
|
|
381
450
|
const match = route.match(/^\/github\/accounts\/([^/]+)(\/repos|\/ssh)?$/);
|
|
382
451
|
if (match) {
|
|
383
452
|
const [, id, action] = match;
|
|
@@ -427,7 +496,7 @@ export function createManagerServer({
|
|
|
427
496
|
const eventClients = new Set();
|
|
428
497
|
|
|
429
498
|
const safeSend = (ws, message) => {
|
|
430
|
-
if (ws.readyState !== ws.OPEN) return;
|
|
499
|
+
if (ws.revoked || ws.readyState !== ws.OPEN) return;
|
|
431
500
|
if (ws.bufferedAmount > SLOW_CLIENT_BYTES) {
|
|
432
501
|
// The client can reconnect and will receive a fresh snapshot.
|
|
433
502
|
ws.close(4008, 'client too slow');
|
|
@@ -459,10 +528,11 @@ export function createManagerServer({
|
|
|
459
528
|
news?.on('updated', () => broadcast({ type: 'news.updated' }));
|
|
460
529
|
changelog?.on('updated', () => broadcast({ type: 'changelog.updated' }));
|
|
461
530
|
github?.on('updated', () => broadcast({ type: 'github.updated' }));
|
|
531
|
+
remoteAccess?.on('updated', () => broadcast({ type: 'remote-access.updated' }));
|
|
462
532
|
|
|
463
533
|
function handleEvents(ws) {
|
|
464
534
|
eventClients.add(ws);
|
|
465
|
-
safeSend(ws, { type: 'hello', version, pid: process.pid, startedAt, launcher, upgrade: upgradeInfo(), sessions: manager.list() });
|
|
535
|
+
safeSend(ws, { type: 'hello', version, pid: process.pid, startedAt, launcher, folderOpener: folderOpener.describe(), folderPicker: folderPicker.describe(), remoteAccess: remoteAccess ? { available: true } : null, upgrade: upgradeInfo(), sessions: manager.list() });
|
|
466
536
|
ws.on('close', () => eventClients.delete(ws));
|
|
467
537
|
ws.on('message', () => { /* events socket is server -> client only */ });
|
|
468
538
|
}
|
|
@@ -480,9 +550,10 @@ export function createManagerServer({
|
|
|
480
550
|
safeSend(ws, message);
|
|
481
551
|
if (message.type === 'removed') ws.close(4410, 'session removed');
|
|
482
552
|
});
|
|
553
|
+
ws.detachTerminal = detach;
|
|
483
554
|
ws.on('close', detach);
|
|
484
555
|
ws.on('message', (raw, isBinary) => {
|
|
485
|
-
if (isBinary) return;
|
|
556
|
+
if (isBinary || ws.revoked) return;
|
|
486
557
|
let msg;
|
|
487
558
|
try { msg = JSON.parse(raw.toString('utf8')); } catch { return; }
|
|
488
559
|
if (msg.type === 'input' && typeof msg.data === 'string') session.input(msg.data);
|
|
@@ -506,6 +577,7 @@ export function createManagerServer({
|
|
|
506
577
|
const termMatch = url.pathname.match(/^\/api\/v1\/sessions\/([a-f0-9]+)\/terminal$/);
|
|
507
578
|
if (url.pathname !== `${API}/events` && !termMatch) return reject(404, 'Not Found');
|
|
508
579
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
580
|
+
ws.source = { headers: { host: req.headers.host, origin: req.headers.origin } };
|
|
509
581
|
ws.isAlive = true;
|
|
510
582
|
ws.on('pong', () => { ws.isAlive = true; });
|
|
511
583
|
ws.on('error', () => {});
|
|
@@ -525,6 +597,21 @@ export function createManagerServer({
|
|
|
525
597
|
|
|
526
598
|
return {
|
|
527
599
|
server,
|
|
600
|
+
setAccessPolicy(next) {
|
|
601
|
+
access = normalizeAccess(next);
|
|
602
|
+
policyVersion++;
|
|
603
|
+
securityHeaders = headersForAccess();
|
|
604
|
+
for (const ws of wss.clients) {
|
|
605
|
+
try { checkRequestSource(ws.source); } catch {
|
|
606
|
+
ws.revoked = true;
|
|
607
|
+
ws.detachTerminal?.();
|
|
608
|
+
ws.close(4403, 'Remote access changed');
|
|
609
|
+
const timer = setTimeout(() => ws.terminate(), 500);
|
|
610
|
+
timer.unref();
|
|
611
|
+
ws.once('close', () => clearTimeout(timer));
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
},
|
|
528
615
|
get port() { return boundPort; },
|
|
529
616
|
get url() { return `http://127.0.0.1:${boundPort}`; },
|
|
530
617
|
listen() {
|