@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.
- 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/folder-browser.mjs +85 -0
- package/src/manager/main.mjs +11 -9
- package/src/manager/remote-access.mjs +286 -0
- package/src/manager/server.mjs +59 -38
- package/src/manager/tailscale.mjs +201 -0
- package/web/app.js +229 -33
- package/web/folders.js +17 -0
- package/web/index.html +107 -2
- package/web/remote-access.js +236 -0
- package/web/styles.css +57 -0
- package/src/manager/folder-picker.mjs +0 -116
- 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
|
+
}
|
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,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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}`, ...
|
|
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:
|
|
273
|
-
|
|
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 === '/
|
|
282
|
-
|
|
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:
|
|
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() {
|