@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.
Files changed (56) hide show
  1. package/README.md +44 -10
  2. package/bin/agent-guild.mjs +2 -0
  3. package/node_modules/qrcode-generator/README.md +101 -0
  4. package/node_modules/qrcode-generator/dist/qrcode.d.ts +57 -0
  5. package/node_modules/qrcode-generator/dist/qrcode.js +2297 -0
  6. package/node_modules/qrcode-generator/dist/qrcode.mjs +2237 -0
  7. package/node_modules/qrcode-generator/dist/qrcode_SJIS.js +30 -0
  8. package/node_modules/qrcode-generator/dist/qrcode_SJIS.mjs +23 -0
  9. package/node_modules/qrcode-generator/dist/qrcode_UTF8.js +26 -0
  10. package/node_modules/qrcode-generator/dist/qrcode_UTF8.mjs +50 -0
  11. package/node_modules/qrcode-generator/experiment/vt/package-lock.json +6184 -0
  12. package/node_modules/qrcode-generator/experiment/vt/package.json +19 -0
  13. package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode.ts +2311 -0
  14. package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode_SJIS.ts +25 -0
  15. package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode_UTF8.ts +50 -0
  16. package/node_modules/qrcode-generator/experiment/vt/src/main/types.ts +80 -0
  17. package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode-test-impl.js +353 -0
  18. package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode.test.mjs +5 -0
  19. package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode_SJIS.test.mjs +10 -0
  20. package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode_UTF8.test.mjs +9 -0
  21. package/node_modules/qrcode-generator/experiment/vt/tsconfig.json +25 -0
  22. package/node_modules/qrcode-generator/experiment/vt/vite.config.js +31 -0
  23. package/node_modules/qrcode-generator/experiment/vt/web-test-runner.config.mjs +7 -0
  24. package/node_modules/qrcode-generator/package.json +37 -0
  25. package/node_modules/qrcode-generator/test/qrcode-test-impl.js +353 -0
  26. package/node_modules/qrcode-generator/test/qrcode.test.html +18 -0
  27. package/node_modules/qrcode-generator/test/qrcode.test.mjs +5 -0
  28. package/node_modules/qrcode-generator/test/qrcode_SJIS.test.html +21 -0
  29. package/node_modules/qrcode-generator/test/qrcode_SJIS.test.mjs +10 -0
  30. package/node_modules/qrcode-generator/test/qrcode_UTF8.test.html +19 -0
  31. package/node_modules/qrcode-generator/test/qrcode_UTF8.test.mjs +9 -0
  32. package/node_modules/qrcode-generator/web-test-runner.config.mjs +8 -0
  33. package/package.json +3 -2
  34. package/src/manager/access-policy.mjs +39 -0
  35. package/src/manager/config.mjs +1 -0
  36. package/src/manager/folder-opener.mjs +96 -0
  37. package/src/manager/folder-picker.mjs +116 -0
  38. package/src/manager/folder-picker.ps1 +84 -0
  39. package/src/manager/github-views.mjs +171 -0
  40. package/src/manager/github.mjs +56 -1
  41. package/src/manager/main.mjs +11 -6
  42. package/src/manager/remote-access.mjs +286 -0
  43. package/src/manager/server.mjs +105 -18
  44. package/src/manager/tailscale.mjs +201 -0
  45. package/web/activity-favicon.js +71 -0
  46. package/web/app.js +1154 -98
  47. package/web/index.html +205 -92
  48. package/web/layout.js +75 -0
  49. package/web/remote-access.js +236 -0
  50. package/web/repo-search.js +91 -0
  51. package/web/skins/gnomeland/skin.css +1 -1
  52. package/web/skins/goblinville/skin.css +2 -2
  53. package/web/skins/grove/skin.css +1 -1
  54. package/web/skins/guild/skin.css +3 -3
  55. package/web/skins/orbital/skin.css +1 -1
  56. 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
+ }
@@ -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}`, ...extraOrigins]);
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
- ...SECURITY_HEADERS,
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() {