@oddessentials/agent-guild 0.26.0 → 0.28.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 (47) 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/folder-browser.mjs +85 -0
  37. package/src/manager/main.mjs +11 -9
  38. package/src/manager/remote-access.mjs +286 -0
  39. package/src/manager/server.mjs +59 -38
  40. package/src/manager/tailscale.mjs +201 -0
  41. package/web/app.js +229 -33
  42. package/web/folders.js +17 -0
  43. package/web/index.html +107 -2
  44. package/web/remote-access.js +236 -0
  45. package/web/styles.css +57 -0
  46. package/src/manager/folder-picker.mjs +0 -116
  47. package/src/manager/folder-picker.ps1 +0 -84
@@ -0,0 +1,85 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const failure = (status, code, message) => Object.assign(new Error(message), { status, code });
6
+ const byName = (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true }) || (a.name < b.name ? -1 : 1);
7
+
8
+ export function folderSegments(dir, paths = path) {
9
+ const root = paths.parse(dir).root;
10
+ const segments = [{ name: root, path: root }];
11
+ let current = root;
12
+ for (const part of dir.slice(root.length).split(paths.sep).filter(Boolean)) {
13
+ current = paths.join(current, part);
14
+ segments.push({ name: part, path: current });
15
+ }
16
+ return segments;
17
+ }
18
+
19
+ export function createFolderBrowser({ platform = process.platform, home = os.homedir(), limit = 2000, driveTimeoutMs = 500, fsp = fs.promises } = {}) {
20
+ const paths = platform === 'win32' ? path.win32 : path.posix;
21
+
22
+ async function roots() {
23
+ if (platform !== 'win32') return [{ name: '/', path: '/' }];
24
+ const letters = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'];
25
+ const found = await Promise.all(letters.map((letter) => {
26
+ let timer;
27
+ const probe = fsp.stat(`${letter}:\\`).then((stat) => stat.isDirectory(), () => false);
28
+ const late = new Promise((resolve) => { timer = setTimeout(resolve, driveTimeoutMs, false); });
29
+ return Promise.race([probe, late]).finally(() => clearTimeout(timer));
30
+ }));
31
+ return letters.filter((_, i) => found[i]).map((letter) => ({ name: `${letter}:`, path: `${letter}:\\` }));
32
+ }
33
+
34
+ async function nearestFolder(dir) {
35
+ for (;;) {
36
+ try {
37
+ if ((await fsp.stat(dir)).isDirectory()) return dir;
38
+ } catch (error) {
39
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw failure(409, 'folder_unreadable', `Agent Guild cannot read ${dir}.`);
40
+ }
41
+ const parent = paths.dirname(dir);
42
+ if (parent === dir) return home;
43
+ dir = parent;
44
+ }
45
+ }
46
+
47
+ async function entries(dir) {
48
+ let listed;
49
+ try {
50
+ listed = await fsp.readdir(dir, { withFileTypes: true });
51
+ } catch {
52
+ throw failure(409, 'folder_unreadable', `Agent Guild cannot read ${dir}.`);
53
+ }
54
+ const folders = await Promise.all(listed.map(async (entry) => {
55
+ let folder = entry.isDirectory();
56
+ if (!folder && entry.isSymbolicLink()) folder = await fsp.stat(paths.join(dir, entry.name)).then((stat) => stat.isDirectory(), () => false);
57
+ return folder ? { name: entry.name, path: paths.join(dir, entry.name), hidden: entry.name.startsWith('.') } : null;
58
+ }));
59
+ return folders.filter(Boolean).sort(byName);
60
+ }
61
+
62
+ return {
63
+ async list(raw) {
64
+ if (raw !== undefined && raw !== null && (typeof raw !== 'string' || raw.includes('\0'))) {
65
+ throw failure(400, 'bad_path', 'The folder must be a path.');
66
+ }
67
+ let asked = String(raw ?? '').trim();
68
+ if (asked === '~' || asked.startsWith('~/') || asked.startsWith('~\\')) asked = paths.join(home, asked.slice(1));
69
+ const wanted = asked ? paths.resolve(asked) : home;
70
+ const dir = await nearestFolder(wanted);
71
+ const [folders, drives] = await Promise.all([entries(dir), roots()]);
72
+ const parent = paths.dirname(dir);
73
+ return {
74
+ path: dir,
75
+ parent: parent === dir ? null : parent,
76
+ home,
77
+ segments: folderSegments(dir, paths),
78
+ roots: drives,
79
+ entries: folders.slice(0, limit),
80
+ truncated: folders.length > limit,
81
+ note: dir === wanted ? null : wanted,
82
+ };
83
+ },
84
+ };
85
+ }
@@ -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,14 +4,14 @@
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';
11
10
  import { folderOrigin } from './github.mjs';
12
11
  import { createViews } from './github-views.mjs';
13
12
  import { createFolderOpener } from './folder-opener.mjs';
14
- import { createFolderPicker } from './folder-picker.mjs';
13
+ import { createFolderBrowser } from './folder-browser.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,28 +112,36 @@ 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
- folderPicker = createFolderPicker({ resolveCwd: (cwd) => manager.resolveCwd(cwd) }),
117
+ folderBrowser = createFolderBrowser(),
131
118
  /** The double-click launcher file for this platform, or null when the package carries none. */
132
119
  launcher = null,
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 localHosts = () => [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`];
137
+ const allowedHosts = () => new Set([...localHosts(), ...access.hosts]);
138
+ const isLocalClient = (req) => localHosts().includes(String(req.headers.host || '').toLowerCase());
139
+ const folderOpenerFor = (req) => {
140
+ const opener = folderOpener.describe();
141
+ return isLocalClient(req) ? opener : { ...opener, available: false, reason: 'Only available on the computer running Agent Guild.' };
142
+ };
149
143
  const allowedOrigins = () =>
150
- new Set([`http://127.0.0.1:${boundPort}`, `http://localhost:${boundPort}`, `http://[::1]:${boundPort}`, ...extraOrigins]);
144
+ new Set([`http://127.0.0.1:${boundPort}`, `http://localhost:${boundPort}`, `http://[::1]:${boundPort}`, ...access.origins]);
151
145
 
152
146
  /** Blocks DNS-rebinding (Host) and cross-site browser requests (Origin). */
153
147
  function checkRequestSource(req) {
@@ -188,7 +182,7 @@ export function createManagerServer({
188
182
  const notFound = () => sendJson(res, 404, { error: { code: 'not_found', message: 'not found' } });
189
183
  fs.stat(file, (statErr, stat) => {
190
184
  if (statErr || !stat.isFile()) return notFound();
191
- const etag = `W/"${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`;
185
+ const etag = `W/"${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}-${policyVersion}"`;
192
186
  const headers = {
193
187
  ...securityHeaders,
194
188
  'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream',
@@ -258,6 +252,17 @@ export function createManagerServer({
258
252
 
259
253
  requireAuth(req, url);
260
254
 
255
+ if (remoteAccess && route === '/remote-access' && method === 'GET') {
256
+ return sendJson(res, 200, { remoteAccess: remoteAccess.snapshot() });
257
+ }
258
+ if (remoteAccess && route === '/remote-access/check' && method === 'POST') {
259
+ return sendJson(res, 200, { remoteAccess: await remoteAccess.check() });
260
+ }
261
+ if (remoteAccess && route === '/remote-access' && method === 'PUT') {
262
+ const operation = remoteAccess.change(await readJsonBody(req));
263
+ return sendJson(res, 202, { remoteAccess: operation });
264
+ }
265
+
261
266
  if (route === '/info' && method === 'GET') {
262
267
  selfUpdate?.refresh().catch(() => {});
263
268
  return sendJson(res, 200, {
@@ -269,20 +274,18 @@ export function createManagerServer({
269
274
  warnings: registry.warnings,
270
275
  upgrade: upgradeInfo(),
271
276
  launcher,
272
- folderOpener: folderOpener.describe(),
273
- folderPicker: folderPicker.describe(),
277
+ folderOpener: folderOpenerFor(req),
278
+ remoteAccess: remoteAccess ? { available: true } : null,
274
279
  });
275
280
  }
276
281
  if (route === '/open-folder' && method === 'POST') {
277
282
  const { cwd } = await readJsonBody(req);
283
+ if (!isLocalClient(req)) throw new HttpError(403, 'Only available on the computer running Agent Guild.', 'local_only');
278
284
  await folderOpener.open(cwd);
279
285
  return sendJson(res, 200, { ok: true });
280
286
  }
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 }) });
287
+ if (route === '/folders' && method === 'GET') {
288
+ return sendJson(res, 200, await folderBrowser.list(url.searchParams.get('path') ?? undefined));
286
289
  }
287
290
  if (route === '/upgrade' && method === 'POST') {
288
291
  const session = await manager.upgrade();
@@ -496,7 +499,7 @@ export function createManagerServer({
496
499
  const eventClients = new Set();
497
500
 
498
501
  const safeSend = (ws, message) => {
499
- if (ws.readyState !== ws.OPEN) return;
502
+ if (ws.revoked || ws.readyState !== ws.OPEN) return;
500
503
  if (ws.bufferedAmount > SLOW_CLIENT_BYTES) {
501
504
  // The client can reconnect and will receive a fresh snapshot.
502
505
  ws.close(4008, 'client too slow');
@@ -528,10 +531,11 @@ export function createManagerServer({
528
531
  news?.on('updated', () => broadcast({ type: 'news.updated' }));
529
532
  changelog?.on('updated', () => broadcast({ type: 'changelog.updated' }));
530
533
  github?.on('updated', () => broadcast({ type: 'github.updated' }));
534
+ remoteAccess?.on('updated', () => broadcast({ type: 'remote-access.updated' }));
531
535
 
532
- function handleEvents(ws) {
536
+ function handleEvents(ws, req) {
533
537
  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() });
538
+ safeSend(ws, { type: 'hello', version, pid: process.pid, platform: process.platform, startedAt, launcher, folderOpener: folderOpenerFor(req), remoteAccess: remoteAccess ? { available: true } : null, upgrade: upgradeInfo(), sessions: manager.list() });
535
539
  ws.on('close', () => eventClients.delete(ws));
536
540
  ws.on('message', () => { /* events socket is server -> client only */ });
537
541
  }
@@ -549,9 +553,10 @@ export function createManagerServer({
549
553
  safeSend(ws, message);
550
554
  if (message.type === 'removed') ws.close(4410, 'session removed');
551
555
  });
556
+ ws.detachTerminal = detach;
552
557
  ws.on('close', detach);
553
558
  ws.on('message', (raw, isBinary) => {
554
- if (isBinary) return;
559
+ if (isBinary || ws.revoked) return;
555
560
  let msg;
556
561
  try { msg = JSON.parse(raw.toString('utf8')); } catch { return; }
557
562
  if (msg.type === 'input' && typeof msg.data === 'string') session.input(msg.data);
@@ -575,11 +580,12 @@ export function createManagerServer({
575
580
  const termMatch = url.pathname.match(/^\/api\/v1\/sessions\/([a-f0-9]+)\/terminal$/);
576
581
  if (url.pathname !== `${API}/events` && !termMatch) return reject(404, 'Not Found');
577
582
  wss.handleUpgrade(req, socket, head, (ws) => {
583
+ ws.source = { headers: { host: req.headers.host, origin: req.headers.origin } };
578
584
  ws.isAlive = true;
579
585
  ws.on('pong', () => { ws.isAlive = true; });
580
586
  ws.on('error', () => {});
581
587
  if (termMatch) handleTerminal(ws, termMatch[1]);
582
- else handleEvents(ws);
588
+ else handleEvents(ws, req);
583
589
  });
584
590
  });
585
591
 
@@ -594,6 +600,21 @@ export function createManagerServer({
594
600
 
595
601
  return {
596
602
  server,
603
+ setAccessPolicy(next) {
604
+ access = normalizeAccess(next);
605
+ policyVersion++;
606
+ securityHeaders = headersForAccess();
607
+ for (const ws of wss.clients) {
608
+ try { checkRequestSource(ws.source); } catch {
609
+ ws.revoked = true;
610
+ ws.detachTerminal?.();
611
+ ws.close(4403, 'Remote access changed');
612
+ const timer = setTimeout(() => ws.terminate(), 500);
613
+ timer.unref();
614
+ ws.once('close', () => clearTimeout(timer));
615
+ }
616
+ }
617
+ },
597
618
  get port() { return boundPort; },
598
619
  get url() { return `http://127.0.0.1:${boundPort}`; },
599
620
  listen() {