@oddessentials/agent-guild 0.26.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 (43) hide show
  1. package/README.md +3 -3
  2. package/bin/agent-guild.mjs +2 -2
  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 +2 -1
  34. package/src/manager/access-policy.mjs +39 -0
  35. package/src/manager/config.mjs +1 -0
  36. package/src/manager/main.mjs +11 -9
  37. package/src/manager/remote-access.mjs +286 -0
  38. package/src/manager/server.mjs +45 -27
  39. package/src/manager/tailscale.mjs +201 -0
  40. package/web/app.js +18 -4
  41. package/web/index.html +65 -0
  42. package/web/remote-access.js +236 -0
  43. package/web/styles.css +23 -0
@@ -12,7 +12,8 @@ import { ModelStats } from './model-stats.mjs';
12
12
  import { NewsFeed } from './news.mjs';
13
13
  import { Changelog } from './changelog.mjs';
14
14
  import { GitHub } from './github.mjs';
15
- import { createManagerServer, parseAllowedHosts } from './server.mjs';
15
+ import { createManagerServer } from './server.mjs';
16
+ import { RemoteAccess, loadRemoteAccess } from './remote-access.mjs';
16
17
  import { SelfUpdate } from './self-update.mjs';
17
18
  import { resolveBaseEnv, pathReader } from './shell-env.mjs';
18
19
  import { writeReportShims } from './report-shims.mjs';
@@ -38,12 +39,13 @@ const rootDir = path.resolve(here, '../..');
38
39
  const VERSION_REFRESH_MS = 60 * 60 * 1000;
39
40
 
40
41
  /** `version`, `packageFile` and `github` (GitHub's URLs and client id) stand in for the real ones in tests. */
41
- export async function startManager({ port = resolvePort(), host = DEFAULT_HOST, sessionDefaults, version = VERSION, packageFile = PACKAGE_FILE, github: githubOptions = {} } = {}) {
42
+ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST, sessionDefaults, version = VERSION, packageFile = PACKAGE_FILE, github: githubOptions = {}, remoteAccess: remoteOptions = {} } = {}) {
42
43
  // Validate before creating files, processes or timers, so a typo fails startup cleanly.
43
- const extraHosts = parseAllowedHosts(process.env.AGENT_GUILD_ALLOWED_HOSTS);
44
+ const remoteSettings = loadRemoteAccess(paths.remoteAccess);
44
45
  ensureDataDir();
45
46
  const token = loadOrCreateToken();
46
47
  const baseEnv = resolveBaseEnv();
48
+ const remoteAccess = new RemoteAccess({ file: paths.remoteAccess, env: baseEnv, loaded: remoteSettings, ...remoteOptions });
47
49
  const webDir = path.join(rootDir, 'web');
48
50
  // The hooks in examples/ call `agent-guild-report` by name; these shims
49
51
  // make that name resolve inside every session. Regenerated at each start
@@ -95,6 +97,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
95
97
  if (closing) return closing;
96
98
  console.log(`[manager] ${restart ? 'restarting' : 'stopping'} (${reason}); ending ${manager.sessions.size} session(s)`);
97
99
  clearInterval(versionTimer);
100
+ remoteAccess.close();
98
101
  github.close();
99
102
  removeRuntimeFile();
100
103
  // Sessions end before the API closes, and the last event says whether
@@ -117,9 +120,6 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
117
120
  return closing;
118
121
  };
119
122
 
120
- const extraOrigins = (process.env.AGENT_GUILD_ALLOWED_ORIGINS || '')
121
- .split(',').map((s) => s.trim()).filter(Boolean);
122
-
123
123
  api = createManagerServer({
124
124
  manager,
125
125
  registry,
@@ -135,11 +135,13 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
135
135
  webDir,
136
136
  version,
137
137
  selfUpdate,
138
- extraHosts,
139
- extraOrigins,
138
+ extraHosts: remoteSettings.config.access.hosts,
139
+ extraOrigins: remoteSettings.config.access.origins,
140
+ remoteAccess,
140
141
  launcher: launcherPath(),
141
142
  onShutdownRequest: ({ restart = false } = {}) => shutdown('requested via API', { restart }).then(() => process.exit(0)),
142
143
  });
144
+ remoteAccess.attach({ apply: (access) => api.setAccessPolicy(access), getTarget: () => api.url });
143
145
 
144
146
  try {
145
147
  await api.listen();
@@ -165,7 +167,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
165
167
  });
166
168
  console.log(`[manager] Agent Guild ${version} listening on ${api.url} (pid ${process.pid})`);
167
169
  refreshVersions();
168
- return { api, manager, registry, token, shutdown };
170
+ return { api, manager, registry, token, shutdown, remoteAccess };
169
171
  }
170
172
 
171
173
  function isEntryPoint() {
@@ -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
+ }
@@ -4,7 +4,6 @@
4
4
  import http from 'node:http';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
- import { isIP } from 'node:net';
8
7
  import { createRequire } from 'node:module';
9
8
  import { WebSocketServer } from 'ws';
10
9
  import { timingSafeEqualString } from './session-manager.mjs';
@@ -12,6 +11,7 @@ import { folderOrigin } from './github.mjs';
12
11
  import { createViews } from './github-views.mjs';
13
12
  import { createFolderOpener } from './folder-opener.mjs';
14
13
  import { createFolderPicker } from './folder-picker.mjs';
14
+ import { normalizeAccess } from './access-policy.mjs';
15
15
 
16
16
  const require = createRequire(import.meta.url);
17
17
  const API = '/api/v1';
@@ -41,6 +41,7 @@ function vendorFiles() {
41
41
  '/vendor/xterm/xterm.css': path.join(pkgDir('@xterm/xterm'), 'css/xterm.css'),
42
42
  '/vendor/xterm/addon-fit.js': path.join(pkgDir('@xterm/addon-fit'), 'lib/addon-fit.js'),
43
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'),
44
45
  };
45
46
  }
46
47
 
@@ -62,21 +63,6 @@ class HttpError extends Error {
62
63
  }
63
64
  }
64
65
 
65
- /** Exact Host authorities, also safe to include as CSP WebSocket sources. */
66
- export function parseAllowedHosts(value = '') {
67
- const entries = value.split(',').map((entry) => entry.trim().toLowerCase()).filter(Boolean);
68
- for (const entry of entries) {
69
- const match = /^(\[[0-9a-f:.]+\]|[a-z0-9.-]+)(?::([1-9]\d{0,4}))?$/.exec(entry);
70
- const name = match?.[1];
71
- const validName = name?.startsWith('[') ? isIP(name.slice(1, -1)) === 6
72
- : name && name.length <= 253 && name.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
73
- if (!validName || (match[2] && Number(match[2]) > 65535)) {
74
- throw new Error(`Invalid AGENT_GUILD_ALLOWED_HOSTS entry ${JSON.stringify(entry)}. Use a hostname with an optional port, such as guild.example.ts.net or guild.example.ts.net:8443; URLs and wildcards are not allowed.`);
75
- }
76
- }
77
- return [...new Set(entries)];
78
- }
79
-
80
66
  function readJsonBody(req) {
81
67
  return new Promise((resolve, reject) => {
82
68
  let size = 0;
@@ -126,6 +112,7 @@ export function createManagerServer({
126
112
  selfUpdate = null,
127
113
  extraHosts = [],
128
114
  extraOrigins = [],
115
+ remoteAccess = null,
129
116
  folderOpener = createFolderOpener({ resolveCwd: (cwd) => manager.resolveCwd(cwd) }),
130
117
  folderPicker = createFolderPicker({ resolveCwd: (cwd) => manager.resolveCwd(cwd) }),
131
118
  /** The double-click launcher file for this platform, or null when the package carries none. */
@@ -133,21 +120,22 @@ export function createManagerServer({
133
120
  /** @type {(opts: { restart: boolean }) => void} */
134
121
  onShutdownRequest = () => {},
135
122
  }) {
136
- const proxyHosts = parseAllowedHosts(extraHosts.join(','));
137
- const socketSources = proxyHosts.flatMap((authority) => [`ws://${authority}`, `wss://${authority}`]);
138
- const securityHeaders = {
123
+ let access = normalizeAccess({ hosts: extraHosts, origins: extraOrigins });
124
+ let policyVersion = 0;
125
+ const headersForAccess = () => ({
139
126
  ...SECURITY_HEADERS,
140
127
  'Content-Security-Policy': SECURITY_HEADERS['Content-Security-Policy'].replace(
141
- "connect-src 'self'", ["connect-src 'self'", ...socketSources].join(' ')),
142
- };
128
+ "connect-src 'self'", ["connect-src 'self'", ...access.hosts.flatMap((authority) => [`ws://${authority}`, `wss://${authority}`])].join(' ')),
129
+ });
130
+ let securityHeaders = headersForAccess();
143
131
  const upgradeInfo = () => (selfUpdate ? selfUpdate.describe() : null);
144
132
  const startedAt = new Date().toISOString();
145
133
  const vendor = vendorFiles();
146
134
  let boundPort = port;
147
135
 
148
- const allowedHosts = () => new Set([`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`, ...proxyHosts]);
136
+ const allowedHosts = () => new Set([`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`, ...access.hosts]);
149
137
  const allowedOrigins = () =>
150
- 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]);
151
139
 
152
140
  /** Blocks DNS-rebinding (Host) and cross-site browser requests (Origin). */
153
141
  function checkRequestSource(req) {
@@ -188,7 +176,7 @@ export function createManagerServer({
188
176
  const notFound = () => sendJson(res, 404, { error: { code: 'not_found', message: 'not found' } });
189
177
  fs.stat(file, (statErr, stat) => {
190
178
  if (statErr || !stat.isFile()) return notFound();
191
- 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}"`;
192
180
  const headers = {
193
181
  ...securityHeaders,
194
182
  'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream',
@@ -258,6 +246,17 @@ export function createManagerServer({
258
246
 
259
247
  requireAuth(req, url);
260
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
+
261
260
  if (route === '/info' && method === 'GET') {
262
261
  selfUpdate?.refresh().catch(() => {});
263
262
  return sendJson(res, 200, {
@@ -271,6 +270,7 @@ export function createManagerServer({
271
270
  launcher,
272
271
  folderOpener: folderOpener.describe(),
273
272
  folderPicker: folderPicker.describe(),
273
+ remoteAccess: remoteAccess ? { available: true } : null,
274
274
  });
275
275
  }
276
276
  if (route === '/open-folder' && method === 'POST') {
@@ -496,7 +496,7 @@ export function createManagerServer({
496
496
  const eventClients = new Set();
497
497
 
498
498
  const safeSend = (ws, message) => {
499
- if (ws.readyState !== ws.OPEN) return;
499
+ if (ws.revoked || ws.readyState !== ws.OPEN) return;
500
500
  if (ws.bufferedAmount > SLOW_CLIENT_BYTES) {
501
501
  // The client can reconnect and will receive a fresh snapshot.
502
502
  ws.close(4008, 'client too slow');
@@ -528,10 +528,11 @@ export function createManagerServer({
528
528
  news?.on('updated', () => broadcast({ type: 'news.updated' }));
529
529
  changelog?.on('updated', () => broadcast({ type: 'changelog.updated' }));
530
530
  github?.on('updated', () => broadcast({ type: 'github.updated' }));
531
+ remoteAccess?.on('updated', () => broadcast({ type: 'remote-access.updated' }));
531
532
 
532
533
  function handleEvents(ws) {
533
534
  eventClients.add(ws);
534
- safeSend(ws, { type: 'hello', version, pid: process.pid, startedAt, launcher, folderOpener: folderOpener.describe(), folderPicker: folderPicker.describe(), 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() });
535
536
  ws.on('close', () => eventClients.delete(ws));
536
537
  ws.on('message', () => { /* events socket is server -> client only */ });
537
538
  }
@@ -549,9 +550,10 @@ export function createManagerServer({
549
550
  safeSend(ws, message);
550
551
  if (message.type === 'removed') ws.close(4410, 'session removed');
551
552
  });
553
+ ws.detachTerminal = detach;
552
554
  ws.on('close', detach);
553
555
  ws.on('message', (raw, isBinary) => {
554
- if (isBinary) return;
556
+ if (isBinary || ws.revoked) return;
555
557
  let msg;
556
558
  try { msg = JSON.parse(raw.toString('utf8')); } catch { return; }
557
559
  if (msg.type === 'input' && typeof msg.data === 'string') session.input(msg.data);
@@ -575,6 +577,7 @@ export function createManagerServer({
575
577
  const termMatch = url.pathname.match(/^\/api\/v1\/sessions\/([a-f0-9]+)\/terminal$/);
576
578
  if (url.pathname !== `${API}/events` && !termMatch) return reject(404, 'Not Found');
577
579
  wss.handleUpgrade(req, socket, head, (ws) => {
580
+ ws.source = { headers: { host: req.headers.host, origin: req.headers.origin } };
578
581
  ws.isAlive = true;
579
582
  ws.on('pong', () => { ws.isAlive = true; });
580
583
  ws.on('error', () => {});
@@ -594,6 +597,21 @@ export function createManagerServer({
594
597
 
595
598
  return {
596
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
+ },
597
615
  get port() { return boundPort; },
598
616
  get url() { return `http://127.0.0.1:${boundPort}`; },
599
617
  listen() {