@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.
- package/README.md +3 -3
- package/bin/agent-guild.mjs +2 -2
- package/node_modules/qrcode-generator/README.md +101 -0
- package/node_modules/qrcode-generator/dist/qrcode.d.ts +57 -0
- package/node_modules/qrcode-generator/dist/qrcode.js +2297 -0
- package/node_modules/qrcode-generator/dist/qrcode.mjs +2237 -0
- package/node_modules/qrcode-generator/dist/qrcode_SJIS.js +30 -0
- package/node_modules/qrcode-generator/dist/qrcode_SJIS.mjs +23 -0
- package/node_modules/qrcode-generator/dist/qrcode_UTF8.js +26 -0
- package/node_modules/qrcode-generator/dist/qrcode_UTF8.mjs +50 -0
- package/node_modules/qrcode-generator/experiment/vt/package-lock.json +6184 -0
- package/node_modules/qrcode-generator/experiment/vt/package.json +19 -0
- package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode.ts +2311 -0
- package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode_SJIS.ts +25 -0
- package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode_UTF8.ts +50 -0
- package/node_modules/qrcode-generator/experiment/vt/src/main/types.ts +80 -0
- package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode-test-impl.js +353 -0
- package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode.test.mjs +5 -0
- package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode_SJIS.test.mjs +10 -0
- package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode_UTF8.test.mjs +9 -0
- package/node_modules/qrcode-generator/experiment/vt/tsconfig.json +25 -0
- package/node_modules/qrcode-generator/experiment/vt/vite.config.js +31 -0
- package/node_modules/qrcode-generator/experiment/vt/web-test-runner.config.mjs +7 -0
- package/node_modules/qrcode-generator/package.json +37 -0
- package/node_modules/qrcode-generator/test/qrcode-test-impl.js +353 -0
- package/node_modules/qrcode-generator/test/qrcode.test.html +18 -0
- package/node_modules/qrcode-generator/test/qrcode.test.mjs +5 -0
- package/node_modules/qrcode-generator/test/qrcode_SJIS.test.html +21 -0
- package/node_modules/qrcode-generator/test/qrcode_SJIS.test.mjs +10 -0
- package/node_modules/qrcode-generator/test/qrcode_UTF8.test.html +19 -0
- package/node_modules/qrcode-generator/test/qrcode_UTF8.test.mjs +9 -0
- package/node_modules/qrcode-generator/web-test-runner.config.mjs +8 -0
- package/package.json +2 -1
- package/src/manager/access-policy.mjs +39 -0
- package/src/manager/config.mjs +1 -0
- package/src/manager/main.mjs +11 -9
- package/src/manager/remote-access.mjs +286 -0
- package/src/manager/server.mjs +45 -27
- package/src/manager/tailscale.mjs +201 -0
- package/web/app.js +18 -4
- package/web/index.html +65 -0
- package/web/remote-access.js +236 -0
- package/web/styles.css +23 -0
package/src/manager/main.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
package/src/manager/server.mjs
CHANGED
|
@@ -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
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
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'", ...
|
|
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}`, ...
|
|
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}`, ...
|
|
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() {
|