@oddessentials/agent-guild 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -10
- package/bin/agent-guild.mjs +2 -0
- 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 +3 -2
- package/src/manager/access-policy.mjs +39 -0
- package/src/manager/config.mjs +1 -0
- package/src/manager/folder-opener.mjs +96 -0
- package/src/manager/folder-picker.mjs +116 -0
- package/src/manager/folder-picker.ps1 +84 -0
- package/src/manager/github-views.mjs +171 -0
- package/src/manager/github.mjs +56 -1
- package/src/manager/main.mjs +11 -6
- package/src/manager/remote-access.mjs +286 -0
- package/src/manager/server.mjs +105 -18
- package/src/manager/tailscale.mjs +201 -0
- package/web/activity-favicon.js +71 -0
- package/web/app.js +1154 -98
- package/web/index.html +205 -92
- package/web/layout.js +75 -0
- package/web/remote-access.js +236 -0
- package/web/repo-search.js +91 -0
- package/web/skins/gnomeland/skin.css +1 -1
- package/web/skins/goblinville/skin.css +2 -2
- package/web/skins/grove/skin.css +1 -1
- package/web/skins/guild/skin.css +3 -3
- package/web/skins/orbital/skin.css +1 -1
- package/web/styles.css +269 -38
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { resolveCommand } from './command-resolver.mjs';
|
|
5
|
+
import { parseAllowedHosts } from './access-policy.mjs';
|
|
6
|
+
|
|
7
|
+
const object = (value) => value && typeof value === 'object' && !Array.isArray(value);
|
|
8
|
+
export const remoteError = (code, message, status = 409) => Object.assign(new Error(message), { code, status });
|
|
9
|
+
|
|
10
|
+
export function findTailscale(env = process.env, platform = process.platform, exists = fs.existsSync) {
|
|
11
|
+
const onPath = resolveCommand(platform === 'win32' ? 'tailscale.exe' : 'tailscale', env, platform);
|
|
12
|
+
const candidates = [onPath];
|
|
13
|
+
if (platform === 'win32') {
|
|
14
|
+
for (const dir of [env.ProgramFiles, env['ProgramFiles(x86)'], 'C:\\Program Files']) {
|
|
15
|
+
if (dir) candidates.push(path.win32.join(dir, 'Tailscale', 'tailscale.exe'));
|
|
16
|
+
}
|
|
17
|
+
} else if (platform === 'darwin') {
|
|
18
|
+
candidates.push('/Applications/Tailscale.app/Contents/MacOS/Tailscale', '/usr/local/bin/tailscale', '/opt/homebrew/bin/tailscale');
|
|
19
|
+
} else candidates.push('/usr/bin/tailscale', '/usr/local/bin/tailscale');
|
|
20
|
+
return candidates.find((file) => file && (platform !== 'win32' || /\.exe$/i.test(file)) && exists(file)) || null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function approvalLink(output) {
|
|
24
|
+
for (const match of output.matchAll(/https:\/\/[^\s<>"']+/g)) {
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(match[0]);
|
|
27
|
+
if (url.hostname === 'login.tailscale.com' && !url.username && !url.password && !url.port) return url.href;
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function runTailscale(file, args, { env = process.env, signal, onApproval, timeout = 15000 } = {}) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
let output = '';
|
|
36
|
+
const child = execFile(file, args, {
|
|
37
|
+
env: { ...env, TAILSCALE_BE_CLI: '1' }, windowsHide: true, timeout, maxBuffer: 1024 * 1024, encoding: 'utf8', signal,
|
|
38
|
+
}, (error, stdout, stderr) => {
|
|
39
|
+
if (!error) return resolve(stdout);
|
|
40
|
+
const detail = `${stderr || ''}\n${stdout || ''}`.trim().slice(-2000);
|
|
41
|
+
const url = approvalLink(detail || output);
|
|
42
|
+
if (url) return reject(Object.assign(remoteError('approval_required', 'Approve HTTPS in Tailscale, then continue setup.'), { approvalUrl: url }));
|
|
43
|
+
if (/access denied|permission denied|must be.*(?:root|admin)|sudo tailscale|not permitted|serve config denied/i.test(detail)) {
|
|
44
|
+
return reject(remoteError('permission_required', 'Tailscale needs permission to configure remote access.'));
|
|
45
|
+
}
|
|
46
|
+
if (error.killed || error.code === 'ABORT_ERR') return reject(remoteError('tailscale_timeout', 'Tailscale did not finish. Check its connection and try again.'));
|
|
47
|
+
reject(remoteError('tailscale_error', detail || 'Could not communicate with Tailscale. Open Tailscale and check its connection.'));
|
|
48
|
+
});
|
|
49
|
+
const collect = (chunk) => {
|
|
50
|
+
output = (output + chunk).slice(-8192);
|
|
51
|
+
const url = approvalLink(output);
|
|
52
|
+
if (url) onApproval?.(url);
|
|
53
|
+
};
|
|
54
|
+
child.stdout?.on('data', collect);
|
|
55
|
+
child.stderr?.on('data', collect);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseJson(raw) {
|
|
60
|
+
let value;
|
|
61
|
+
try { value = JSON.parse(raw); } catch {}
|
|
62
|
+
if (value === null) return {};
|
|
63
|
+
if (!object(value)) throw remoteError('tailscale_response', 'Tailscale returned an unreadable status. Update Tailscale and try again.');
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function validateServeConfig(config) {
|
|
68
|
+
if (!object(config)) throw remoteError('tailscale_response', 'Tailscale returned an unreadable Serve configuration.');
|
|
69
|
+
for (const key of ['TCP', 'Web', 'AllowFunnel', 'Foreground', 'Services']) {
|
|
70
|
+
if (config[key] !== undefined && config[key] !== null && !object(config[key])) {
|
|
71
|
+
throw remoteError('tailscale_response', 'Tailscale returned an unsupported Serve configuration.');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const [port, value] of Object.entries(config.TCP || {})) {
|
|
75
|
+
if (!/^[1-9]\d{0,4}$/.test(port) || Number(port) > 65535 || !object(value)) {
|
|
76
|
+
throw remoteError('tailscale_response', 'Tailscale returned an unsupported TCP configuration.');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const value of Object.values(config.Web || {})) {
|
|
80
|
+
if (!object(value) || !object(value.Handlers) || Object.values(value.Handlers).some((handler) => !object(handler))) {
|
|
81
|
+
throw remoteError('tailscale_response', 'Tailscale returned an unsupported web configuration.');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (Object.values(config.AllowFunnel || {}).some((value) => typeof value !== 'boolean')) {
|
|
85
|
+
throw remoteError('tailscale_response', 'Tailscale returned an unsupported Funnel configuration.');
|
|
86
|
+
}
|
|
87
|
+
for (const value of Object.values(config.Foreground || {})) {
|
|
88
|
+
if (!object(value) || value.Foreground) throw remoteError('tailscale_response', 'Tailscale returned an unsupported foreground configuration.');
|
|
89
|
+
validateServeConfig(value);
|
|
90
|
+
}
|
|
91
|
+
return config;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ordered = (value) => Array.isArray(value) ? value.map(ordered)
|
|
95
|
+
: object(value) ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, ordered(value[key])])) : value;
|
|
96
|
+
export const sameConfig = (a, b) => JSON.stringify(ordered(a)) === JSON.stringify(ordered(b));
|
|
97
|
+
export const routeAuthority = (route) => `${route.host}:${route.port}`;
|
|
98
|
+
export const routeUrl = (route) => `https://${route.host}${route.port === 443 ? '' : `:${route.port}`}`;
|
|
99
|
+
|
|
100
|
+
function foregroundPort(config, port) {
|
|
101
|
+
return Object.values(config.Foreground || {}).some((entry) => entry.TCP?.[port] || Object.keys(entry.Web || {}).some((key) => key.endsWith(`:${port}`)));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function portOccupied(config, port) {
|
|
105
|
+
return Boolean(config.TCP?.[port] || Object.keys(config.Web || {}).some((key) => key.endsWith(`:${port}`))
|
|
106
|
+
|| Object.keys(config.AllowFunnel || {}).some((key) => key.endsWith(`:${port}`)) || foregroundPort(config, port));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function routeMatches(config, route) {
|
|
110
|
+
const authority = routeAuthority(route);
|
|
111
|
+
const web = config.Web?.[authority];
|
|
112
|
+
const handler = web?.Handlers?.['/'];
|
|
113
|
+
if (!sameConfig(config.TCP?.[route.port], { HTTPS: true }) || !object(web) || !object(handler)
|
|
114
|
+
|| Object.entries(config.AllowFunnel || {}).some(([key, value]) => value && key.endsWith(`:${route.port}`)) || foregroundPort(config, route.port)
|
|
115
|
+
|| Object.keys(web.Handlers).length !== 1 || Object.keys(web).some((key) => key !== 'Handlers')
|
|
116
|
+
|| Object.keys(handler).some((key) => key !== 'Proxy')
|
|
117
|
+
|| Object.keys(config.Web || {}).some((key) => key !== authority && key.endsWith(`:${route.port}`))) return false;
|
|
118
|
+
try {
|
|
119
|
+
const target = new URL(handler.Proxy);
|
|
120
|
+
return target.origin === new URL(route.target).origin && target.pathname === '/' && !target.search && !target.hash && !target.username && !target.password;
|
|
121
|
+
} catch { return false; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function chooseRoute(info, target, previous = null) {
|
|
125
|
+
if (previous) {
|
|
126
|
+
if (previous.nodeId !== info.nodeId || previous.host !== info.host) {
|
|
127
|
+
throw remoteError('network_changed', 'This computer’s Tailscale identity or address changed. Disable the saved connection before setting up the new address.');
|
|
128
|
+
}
|
|
129
|
+
if (portOccupied(info.config, previous.port) && !routeMatches(info.config, previous)) {
|
|
130
|
+
throw remoteError('route_changed', 'The saved Tailscale route was changed outside Agent Guild. Review it in Tailscale before continuing.');
|
|
131
|
+
}
|
|
132
|
+
return { ...previous, target };
|
|
133
|
+
}
|
|
134
|
+
for (const key of Object.keys(info.config.TCP || {})) {
|
|
135
|
+
const port = Number(key);
|
|
136
|
+
const route = { nodeId: info.nodeId, host: info.host, port, target };
|
|
137
|
+
if (Number.isInteger(port) && port > 0 && port <= 65535 && routeMatches(info.config, route)) return route;
|
|
138
|
+
}
|
|
139
|
+
const port = [443, ...Array.from({ length: 32 }, (_, i) => 8443 + i)].find((candidate) => !portOccupied(info.config, candidate));
|
|
140
|
+
if (!port) throw remoteError('no_port', 'No available HTTPS port was found. Free a Serve port in Tailscale and try again.');
|
|
141
|
+
return { nodeId: info.nodeId, host: info.host, port, target };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export class Tailscale {
|
|
145
|
+
constructor({ env = process.env, platform = process.platform, find = findTailscale, run = runTailscale } = {}) {
|
|
146
|
+
Object.assign(this, { env, platform, find, run });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async command(args, options = {}) {
|
|
150
|
+
const file = this.find(this.env, this.platform);
|
|
151
|
+
if (!file) throw remoteError('not_installed', 'Install Tailscale on this computer to connect it to your network.');
|
|
152
|
+
return this.run(file, args, { env: this.env, ...options });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async inspect(options = {}) {
|
|
156
|
+
const version = (await this.command(['version'], options)).trim().split(/\s/)[0];
|
|
157
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
|
|
158
|
+
if (!match || Number(match[1]) < 1 || (Number(match[1]) === 1 && Number(match[2]) < 52)) {
|
|
159
|
+
throw remoteError('update_required', 'Update Tailscale to version 1.52 or later to set up remote access.');
|
|
160
|
+
}
|
|
161
|
+
const status = parseJson(await this.command(['status', '--json', '--peers=false'], options));
|
|
162
|
+
if (!['Running', 'Starting'].includes(status.BackendState)) {
|
|
163
|
+
throw remoteError('not_connected', 'Open Tailscale and connect this computer to your network, then continue.');
|
|
164
|
+
}
|
|
165
|
+
const host = typeof status.Self?.DNSName === 'string' ? status.Self.DNSName.replace(/\.$/, '').toLowerCase() : null;
|
|
166
|
+
const nodeId = status.Self?.ID;
|
|
167
|
+
if (!host || typeof nodeId !== 'string' || !nodeId || !host.endsWith('.ts.net')) {
|
|
168
|
+
throw remoteError('address_unavailable', 'Tailscale has not assigned this computer an HTTPS address yet. Check its connection and MagicDNS settings.');
|
|
169
|
+
}
|
|
170
|
+
try { parseAllowedHosts(host); } catch { throw remoteError('address_unavailable', 'Tailscale returned an invalid HTTPS address.'); }
|
|
171
|
+
const config = validateServeConfig(parseJson(await this.command(['serve', 'status', '--json'], options)));
|
|
172
|
+
return { version, host, nodeId, config, httpsReady: Array.isArray(status.CertDomains) && status.CertDomains.includes(host) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async enable(route, previous, options = {}) {
|
|
176
|
+
const before = await this.inspect(options);
|
|
177
|
+
if (before.nodeId !== route.nodeId || before.host !== route.host) throw remoteError('network_changed', 'Tailscale changed networks during setup. Check the connection and try again.');
|
|
178
|
+
if (routeMatches(before.config, route)) return;
|
|
179
|
+
if (portOccupied(before.config, route.port) && !(previous && routeMatches(before.config, previous))) {
|
|
180
|
+
throw remoteError('route_changed', 'The selected HTTPS port is now in use. Check the connection and try again.');
|
|
181
|
+
}
|
|
182
|
+
await this.command(['serve', '--bg', `--https=${route.port}`, '--set-path=/', route.target], { ...options, timeout: 30000 });
|
|
183
|
+
const after = await this.inspect(options);
|
|
184
|
+
if (after.nodeId !== route.nodeId || after.host !== route.host || !routeMatches(after.config, route)) {
|
|
185
|
+
throw remoteError('setup_incomplete', 'Tailscale has not finished setting up the private HTTPS route. Complete any approval, then continue.');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async remove(route, options = {}) {
|
|
190
|
+
const info = await this.inspect(options);
|
|
191
|
+
if (info.nodeId !== route.nodeId || info.host !== route.host) throw remoteError('network_changed', 'Connect Tailscale to the original network to remove its saved route.');
|
|
192
|
+
if (!portOccupied(info.config, route.port)) return info;
|
|
193
|
+
if (!routeMatches(info.config, route)) throw remoteError('route_changed', 'This Serve route was changed outside Agent Guild. It has been left in place; review it in Tailscale.');
|
|
194
|
+
await this.command(['serve', '--bg', `--https=${route.port}`, '--set-path=/', 'off'], options);
|
|
195
|
+
const after = await this.inspect(options);
|
|
196
|
+
if (after.nodeId !== route.nodeId || after.host !== route.host || portOccupied(after.config, route.port)) {
|
|
197
|
+
throw remoteError('cleanup_incomplete', 'The Serve route is still present. Check Tailscale and retry cleanup.');
|
|
198
|
+
}
|
|
199
|
+
return after;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The favicon mirrors the card label, including providers that stay active.
|
|
2
|
+
export const isSessionWorking = (session) => session.status !== 'exited' && session.activity === 'active';
|
|
3
|
+
|
|
4
|
+
const FRAME_MS = 100;
|
|
5
|
+
const FRAME_COUNT = 24;
|
|
6
|
+
|
|
7
|
+
/** One cached animation per page. Browser throttling may leave any frame visible. */
|
|
8
|
+
export function createActivityFavicon({
|
|
9
|
+
link, reducedMotion, document = globalThis.document, Image = globalThis.Image,
|
|
10
|
+
setTimer = setTimeout, clearTimer = clearTimeout, now = () => performance.now(),
|
|
11
|
+
}) {
|
|
12
|
+
const original = link?.href;
|
|
13
|
+
let frames = [], timer = null, working = false, paused = false, started = 0;
|
|
14
|
+
|
|
15
|
+
function paint() {
|
|
16
|
+
clearTimer(timer);
|
|
17
|
+
timer = null;
|
|
18
|
+
if (!link) return;
|
|
19
|
+
const animate = working && !paused && !reducedMotion.matches && frames.length > 0;
|
|
20
|
+
const frame = animate ? Math.floor((now() - started) / FRAME_MS) % frames.length : 0;
|
|
21
|
+
const href = working && frames.length ? frames[frame] : original;
|
|
22
|
+
if (link.href !== href) link.href = href;
|
|
23
|
+
if (animate) timer = setTimer(paint, FRAME_MS);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (link) {
|
|
27
|
+
const image = new Image();
|
|
28
|
+
image.onload = () => {
|
|
29
|
+
try {
|
|
30
|
+
const canvas = document.createElement('canvas');
|
|
31
|
+
canvas.width = canvas.height = 32;
|
|
32
|
+
const ctx = canvas.getContext('2d');
|
|
33
|
+
if (!ctx) return;
|
|
34
|
+
// Encode once, not on every tick. Every frame has a distinct working ring.
|
|
35
|
+
frames = Array.from({ length: FRAME_COUNT }, (_, index) => {
|
|
36
|
+
ctx.clearRect(0, 0, 32, 32);
|
|
37
|
+
ctx.drawImage(image, 5, 5, 22, 22);
|
|
38
|
+
ctx.lineWidth = 3;
|
|
39
|
+
ctx.strokeStyle = '#164e3b';
|
|
40
|
+
ctx.beginPath();
|
|
41
|
+
ctx.arc(16, 16, 14, 0, Math.PI * 2);
|
|
42
|
+
ctx.stroke();
|
|
43
|
+
ctx.strokeStyle = '#4ade80';
|
|
44
|
+
ctx.lineCap = 'round';
|
|
45
|
+
ctx.beginPath();
|
|
46
|
+
const angle = index * Math.PI * 2 / FRAME_COUNT - Math.PI / 2;
|
|
47
|
+
ctx.arc(16, 16, 14, angle, angle + Math.PI / 2);
|
|
48
|
+
ctx.stroke();
|
|
49
|
+
return canvas.toDataURL('image/png');
|
|
50
|
+
});
|
|
51
|
+
paint();
|
|
52
|
+
} catch { /* An optional indicator must never prevent the cards from rendering. */ }
|
|
53
|
+
};
|
|
54
|
+
image.onerror = () => {}; // Retain the original favicon if the asset cannot load.
|
|
55
|
+
image.src = original;
|
|
56
|
+
}
|
|
57
|
+
reducedMotion.addEventListener('change', paint);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
setWorking(value) {
|
|
61
|
+
if (working === value) return;
|
|
62
|
+
working = value;
|
|
63
|
+
if (working) started = now();
|
|
64
|
+
paint();
|
|
65
|
+
},
|
|
66
|
+
setPaused(value) {
|
|
67
|
+
paused = value;
|
|
68
|
+
paint();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|