@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.
Files changed (56) hide show
  1. package/README.md +44 -10
  2. package/bin/agent-guild.mjs +2 -0
  3. package/node_modules/qrcode-generator/README.md +101 -0
  4. package/node_modules/qrcode-generator/dist/qrcode.d.ts +57 -0
  5. package/node_modules/qrcode-generator/dist/qrcode.js +2297 -0
  6. package/node_modules/qrcode-generator/dist/qrcode.mjs +2237 -0
  7. package/node_modules/qrcode-generator/dist/qrcode_SJIS.js +30 -0
  8. package/node_modules/qrcode-generator/dist/qrcode_SJIS.mjs +23 -0
  9. package/node_modules/qrcode-generator/dist/qrcode_UTF8.js +26 -0
  10. package/node_modules/qrcode-generator/dist/qrcode_UTF8.mjs +50 -0
  11. package/node_modules/qrcode-generator/experiment/vt/package-lock.json +6184 -0
  12. package/node_modules/qrcode-generator/experiment/vt/package.json +19 -0
  13. package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode.ts +2311 -0
  14. package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode_SJIS.ts +25 -0
  15. package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode_UTF8.ts +50 -0
  16. package/node_modules/qrcode-generator/experiment/vt/src/main/types.ts +80 -0
  17. package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode-test-impl.js +353 -0
  18. package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode.test.mjs +5 -0
  19. package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode_SJIS.test.mjs +10 -0
  20. package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode_UTF8.test.mjs +9 -0
  21. package/node_modules/qrcode-generator/experiment/vt/tsconfig.json +25 -0
  22. package/node_modules/qrcode-generator/experiment/vt/vite.config.js +31 -0
  23. package/node_modules/qrcode-generator/experiment/vt/web-test-runner.config.mjs +7 -0
  24. package/node_modules/qrcode-generator/package.json +37 -0
  25. package/node_modules/qrcode-generator/test/qrcode-test-impl.js +353 -0
  26. package/node_modules/qrcode-generator/test/qrcode.test.html +18 -0
  27. package/node_modules/qrcode-generator/test/qrcode.test.mjs +5 -0
  28. package/node_modules/qrcode-generator/test/qrcode_SJIS.test.html +21 -0
  29. package/node_modules/qrcode-generator/test/qrcode_SJIS.test.mjs +10 -0
  30. package/node_modules/qrcode-generator/test/qrcode_UTF8.test.html +19 -0
  31. package/node_modules/qrcode-generator/test/qrcode_UTF8.test.mjs +9 -0
  32. package/node_modules/qrcode-generator/web-test-runner.config.mjs +8 -0
  33. package/package.json +3 -2
  34. package/src/manager/access-policy.mjs +39 -0
  35. package/src/manager/config.mjs +1 -0
  36. package/src/manager/folder-opener.mjs +96 -0
  37. package/src/manager/folder-picker.mjs +116 -0
  38. package/src/manager/folder-picker.ps1 +84 -0
  39. package/src/manager/github-views.mjs +171 -0
  40. package/src/manager/github.mjs +56 -1
  41. package/src/manager/main.mjs +11 -6
  42. package/src/manager/remote-access.mjs +286 -0
  43. package/src/manager/server.mjs +105 -18
  44. package/src/manager/tailscale.mjs +201 -0
  45. package/web/activity-favicon.js +71 -0
  46. package/web/app.js +1154 -98
  47. package/web/index.html +205 -92
  48. package/web/layout.js +75 -0
  49. package/web/remote-access.js +236 -0
  50. package/web/repo-search.js +91 -0
  51. package/web/skins/gnomeland/skin.css +1 -1
  52. package/web/skins/goblinville/skin.css +2 -2
  53. package/web/skins/grove/skin.css +1 -1
  54. package/web/skins/guild/skin.css +3 -3
  55. package/web/skins/orbital/skin.css +1 -1
  56. package/web/styles.css +269 -38
@@ -0,0 +1,96 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+
6
+ const failure = (status, code, message) => Object.assign(new Error(message), { status, code });
7
+ const isFile = (file) => { try { return fs.statSync(file).isFile(); } catch { return false; } };
8
+
9
+ export function folderOpenTarget({ platform = process.platform, env = process.env, release = os.release(), fileExists = isFile } = {}) {
10
+ const onPath = (name) => (env.PATH || '').split(':').filter((dir) => path.posix.isAbsolute(dir))
11
+ .map((dir) => path.posix.join(dir, name)).find(fileExists);
12
+ let command, kind, label, reason;
13
+ if (platform === 'win32') {
14
+ kind = 'explorer';
15
+ label = 'File Explorer';
16
+ command = path.win32.join(env.SystemRoot || env.WINDIR || 'C:\\Windows', 'explorer.exe');
17
+ } else if (platform === 'darwin') {
18
+ kind = 'finder';
19
+ label = 'Finder';
20
+ command = '/usr/bin/open';
21
+ } else if (platform === 'linux' && (env.WSL_DISTRO_NAME || /microsoft|wsl/i.test(release))) {
22
+ kind = 'explorer';
23
+ label = 'File Explorer';
24
+ command = onPath('explorer.exe');
25
+ reason = 'Windows Explorer is unavailable. Enable Windows interoperability in WSL to open folders.';
26
+ } else if (platform === 'linux') {
27
+ kind = 'desktop';
28
+ label = 'file manager';
29
+ if (env.DISPLAY || env.WAYLAND_DISPLAY) {
30
+ command = onPath('xdg-open');
31
+ reason = 'Install xdg-utils and configure a default file manager to open folders.';
32
+ } else reason = 'Opening folders requires a desktop session on the computer running Agent Guild.';
33
+ } else reason = 'Opening folders is unavailable on this operating system.';
34
+ const available = Boolean(command && fileExists(command));
35
+ return { command, kind, label: label || 'file manager', available, reason: available ? null : reason || `${label} is unavailable on the computer running Agent Guild.` };
36
+ }
37
+
38
+ export function createFolderOpener({ resolveCwd, spawnImpl = spawn, cooldownMs = 1000, handoffMs = 1500, ...targetOptions }) {
39
+ let pending = false, nextOpen = 0;
40
+ return {
41
+ describe() {
42
+ const { available, label, reason } = folderOpenTarget(targetOptions);
43
+ return { available, label, reason };
44
+ },
45
+ async open(cwd) {
46
+ if (cwd !== undefined && (typeof cwd !== 'string' || /[\0\r\n]/.test(cwd))) {
47
+ throw failure(400, 'bad_cwd', 'The working folder must be a directory path.');
48
+ }
49
+ if (typeof cwd === 'string' && /^[a-z][a-z\d+.-]*:\/\//i.test(cwd.trim()) && !/^[a-z]:[/\\]/i.test(cwd.trim())) {
50
+ throw failure(400, 'bad_cwd', 'Enter a folder path, not a URL.');
51
+ }
52
+ if (pending || Date.now() < nextOpen) throw failure(429, 'folder_open_busy', 'Please wait before opening another folder.');
53
+ const target = folderOpenTarget(targetOptions);
54
+ if (!target.available) throw failure(409, 'folder_open_unavailable', target.reason);
55
+ pending = true;
56
+ try {
57
+ const resolved = resolveCwd(cwd);
58
+ let dir;
59
+ try {
60
+ dir = await fs.promises.realpath(resolved);
61
+ if (!(await fs.promises.stat(dir)).isDirectory()) throw new Error();
62
+ } catch {
63
+ throw failure(400, 'bad_cwd', 'The working folder does not exist or cannot be accessed.');
64
+ }
65
+ let args;
66
+ if (target.kind === 'explorer') args = ['.'];
67
+ else if (target.kind === 'finder') {
68
+ // Reveal packages instead of asking Finder to open (and potentially execute) them.
69
+ args = /\.(app|bundle|framework|plugin|kext|prefpane|workflow)$/i.test(dir)
70
+ ? ['-R', dir] : ['-a', '/System/Library/CoreServices/Finder.app', dir];
71
+ } else args = [dir];
72
+ nextOpen = Date.now() + cooldownMs;
73
+ await new Promise((resolve, reject) => {
74
+ let child, timer, settled = false;
75
+ const finish = (error) => {
76
+ if (settled) return;
77
+ settled = true;
78
+ clearTimeout(timer);
79
+ child?.unref();
80
+ if (error) reject(failure(409, 'folder_open_failed', `Could not open ${target.label}. Check that a desktop session and file manager are available on the computer running Agent Guild.`));
81
+ else resolve();
82
+ };
83
+ try {
84
+ child = spawnImpl(target.command, args, {
85
+ cwd: target.kind === 'explorer' ? dir : undefined,
86
+ shell: false, windowsHide: true, detached: true, stdio: 'ignore',
87
+ });
88
+ child.once('error', finish);
89
+ child.once('spawn', () => { timer = setTimeout(() => finish(), handoffMs); });
90
+ child.once('exit', (code) => finish(code !== 0 && !(target.kind === 'explorer' && code === 1)));
91
+ } catch (error) { finish(error); }
92
+ });
93
+ } finally { pending = false; }
94
+ },
95
+ };
96
+ }
@@ -0,0 +1,116 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+
6
+ const TITLE = 'Choose a working folder';
7
+ const failure = (status, code, message) => Object.assign(new Error(message), { status, code });
8
+ const isFile = (file) => { try { return fs.statSync(file).isFile(); } catch { return false; } };
9
+ const isFolder = (dir) => { try { return fs.statSync(dir).isDirectory(); } catch { return false; } };
10
+
11
+ const APPLESCRIPT = [
12
+ 'on run argv',
13
+ 'activate',
14
+ 'try',
15
+ `return POSIX path of (choose folder with prompt "${TITLE}" default location (POSIX file (item 1 of argv)))`,
16
+ 'on error number -128',
17
+ 'return ""',
18
+ 'end try',
19
+ 'end run',
20
+ ];
21
+
22
+ let windowsScript;
23
+ function encodedWindowsScript() {
24
+ windowsScript ??= Buffer.from(fs.readFileSync(new URL('./folder-picker.ps1', import.meta.url), 'utf8'), 'utf16le').toString('base64');
25
+ return windowsScript;
26
+ }
27
+
28
+ export function folderPickTarget({ platform = process.platform, env = process.env, release = os.release(), fileExists = isFile } = {}) {
29
+ const onPath = (name) => (env.PATH || '').split(':').filter((dir) => path.posix.isAbsolute(dir))
30
+ .map((dir) => path.posix.join(dir, name)).find(fileExists);
31
+ let command, kind, translate, reason;
32
+ if (platform === 'win32') {
33
+ kind = 'windows';
34
+ command = path.win32.join(env.SystemRoot || env.WINDIR || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
35
+ } else if (platform === 'darwin') {
36
+ kind = 'mac';
37
+ command = '/usr/bin/osascript';
38
+ } else if (platform === 'linux' && (env.WSL_DISTRO_NAME || /microsoft|wsl/i.test(release))) {
39
+ kind = 'windows';
40
+ translate = onPath('wslpath');
41
+ command = translate && onPath('powershell.exe');
42
+ reason = 'Windows PowerShell is unavailable. Enable Windows interoperability in WSL to choose folders.';
43
+ } else if (platform === 'linux') {
44
+ if (env.DISPLAY || env.WAYLAND_DISPLAY) {
45
+ command = onPath('zenity');
46
+ kind = command ? 'zenity' : 'kdialog';
47
+ command ||= onPath('kdialog');
48
+ reason = 'Install zenity or kdialog to choose folders.';
49
+ } else reason = 'Choosing folders requires a desktop session on the computer running Agent Guild.';
50
+ } else reason = 'Choosing folders is unavailable on this operating system.';
51
+ const available = Boolean(command && fileExists(command));
52
+ return { command, kind, translate, available, reason: available ? null : reason || 'The folder dialog is unavailable on the computer running Agent Guild.' };
53
+ }
54
+
55
+ export function createFolderPicker({ resolveCwd, spawnImpl = spawn, env = process.env, ...targetOptions }) {
56
+ let pending = false;
57
+
58
+ const run = (command, args, { signal, env: childEnv = env } = {}) => new Promise((resolve, reject) => {
59
+ let child, out = '';
60
+ try {
61
+ child = spawnImpl(command, args, { env: childEnv, shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
62
+ } catch (error) { return reject(error); }
63
+ const stop = () => child.kill();
64
+ if (signal?.aborted) stop();
65
+ else signal?.addEventListener('abort', stop, { once: true });
66
+ child.stdout.setEncoding('utf8').on('data', (chunk) => { out += chunk; });
67
+ child.once('error', reject);
68
+ child.once('close', (code) => {
69
+ signal?.removeEventListener('abort', stop);
70
+ resolve({ code, out: out.replace(/[\r\n]+$/, '') });
71
+ });
72
+ });
73
+
74
+ function dialog(target, start, signal) {
75
+ if (target.kind === 'windows') {
76
+ const vars = { AGENT_GUILD_PICK_START: start, AGENT_GUILD_PICK_TITLE: TITLE };
77
+ if (target.translate) vars.WSLENV = [env.WSLENV, 'AGENT_GUILD_PICK_START/p', 'AGENT_GUILD_PICK_TITLE'].filter(Boolean).join(':');
78
+ return run(target.command, ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encodedWindowsScript()], { signal, env: { ...env, ...vars } });
79
+ }
80
+ if (target.kind === 'mac') return run(target.command, [...APPLESCRIPT.flatMap((line) => ['-e', line]), start], { signal });
81
+ if (target.kind === 'zenity') return run(target.command, ['--file-selection', '--directory', `--title=${TITLE}`, `--filename=${path.join(start, path.sep)}`], { signal });
82
+ return run(target.command, ['--title', TITLE, '--getexistingdirectory', start], { signal });
83
+ }
84
+
85
+ return {
86
+ describe() {
87
+ const { available, reason } = folderPickTarget({ env, ...targetOptions });
88
+ return { available, reason };
89
+ },
90
+ async pick(cwd, { signal } = {}) {
91
+ if (cwd !== undefined && (typeof cwd !== 'string' || /[\0\r\n]/.test(cwd))) {
92
+ throw failure(400, 'bad_cwd', 'The working folder must be a directory path.');
93
+ }
94
+ if (pending) throw failure(409, 'folder_pick_busy', 'A folder dialog is already open on the computer running Agent Guild.');
95
+ const target = folderPickTarget({ env, ...targetOptions });
96
+ if (!target.available) throw failure(409, 'folder_pick_unavailable', target.reason);
97
+ let start;
98
+ try { start = resolveCwd(cwd); } catch { start = os.homedir(); }
99
+ pending = true;
100
+ try {
101
+ const broken = () => failure(409, 'folder_pick_failed', 'Could not show the folder dialog. Check that a desktop session is available on the computer running Agent Guild.');
102
+ let result = await dialog(target, start, signal).catch(() => { throw broken(); });
103
+ const cancelled = result.code === 0 || (result.code === 1 && target.kind !== 'windows' && target.kind !== 'mac');
104
+ if (signal?.aborted || (!result.out && cancelled)) return null;
105
+ if (result.code !== 0) throw broken();
106
+ if (target.translate) {
107
+ result = await run(target.translate, ['-u', result.out], { signal }).catch(() => ({ code: 1 }));
108
+ if (result.code !== 0) throw failure(409, 'folder_pick_failed', 'The chosen folder cannot be reached from WSL.');
109
+ }
110
+ const dir = path.resolve(result.out);
111
+ if (!isFolder(dir)) throw failure(409, 'folder_pick_failed', 'The chosen folder cannot be used as a working folder.');
112
+ return dir;
113
+ } finally { pending = false; }
114
+ },
115
+ };
116
+ }
@@ -0,0 +1,84 @@
1
+ $ErrorActionPreference = 'Stop'
2
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
3
+ Add-Type -AssemblyName System.Windows.Forms
4
+ Add-Type -ReferencedAssemblies System.Windows.Forms -TypeDefinition @'
5
+ using System;
6
+ using System.Runtime.InteropServices;
7
+ using System.Windows.Forms;
8
+ public static class GuildFolderPicker {
9
+ [ComImport, Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")] class FileOpenDialog {}
10
+ [ComImport, Guid("42f85136-db7e-439c-85f1-e4075d135fc8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
11
+ interface IFileDialog {
12
+ [PreserveSig] int Show(IntPtr owner);
13
+ void SetFileTypes(uint count, IntPtr specs);
14
+ void SetFileTypeIndex(uint index);
15
+ void GetFileTypeIndex(out uint index);
16
+ void Advise(IntPtr events, out uint cookie);
17
+ void Unadvise(uint cookie);
18
+ void SetOptions(uint options);
19
+ void GetOptions(out uint options);
20
+ void SetDefaultFolder(IShellItem item);
21
+ void SetFolder(IShellItem item);
22
+ void GetFolder(out IShellItem item);
23
+ void GetCurrentSelection(out IShellItem item);
24
+ void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string name);
25
+ void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string name);
26
+ void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string title);
27
+ void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string text);
28
+ void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string text);
29
+ void GetResult(out IShellItem item);
30
+ }
31
+ [ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
32
+ interface IShellItem {
33
+ void BindToHandler(IntPtr context, ref Guid handler, ref Guid iid, out IntPtr result);
34
+ void GetParent(out IShellItem parent);
35
+ void GetDisplayName(uint form, [MarshalAs(UnmanagedType.LPWStr)] out string name);
36
+ }
37
+ [StructLayout(LayoutKind.Sequential)] struct RECT { public int L, T, R, B; }
38
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)]
39
+ static extern void SHCreateItemFromParsingName(string path, IntPtr context, ref Guid iid, out IShellItem item);
40
+ [DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr window, out RECT rect);
41
+ [DllImport("user32.dll")] static extern IntPtr GetForegroundWindow();
42
+ [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window, IntPtr process);
43
+ [DllImport("kernel32.dll")] static extern uint GetCurrentThreadId();
44
+ [DllImport("user32.dll")] static extern bool AttachThreadInput(uint from, uint to, bool attach);
45
+ [DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr window);
46
+
47
+ public static string Pick(string start, string title) {
48
+ var dialog = (IFileDialog)new FileOpenDialog();
49
+ uint options;
50
+ dialog.GetOptions(out options);
51
+ dialog.SetOptions(options | 0x20 | 0x40 | 0x800);
52
+ dialog.SetTitle(title);
53
+ if (!string.IsNullOrEmpty(start)) {
54
+ try {
55
+ IShellItem folder;
56
+ Guid iid = typeof(IShellItem).GUID;
57
+ SHCreateItemFromParsingName(start, IntPtr.Zero, ref iid, out folder);
58
+ dialog.SetFolder(folder);
59
+ } catch {}
60
+ }
61
+ // A dialog from a background process opens behind the browser. An invisible owner that takes
62
+ // the foreground brings it to the front, just inside the window that asked.
63
+ using (var owner = new Form { TopMost = true, ShowInTaskbar = false, FormBorderStyle = FormBorderStyle.None, Opacity = 0, StartPosition = FormStartPosition.Manual }) {
64
+ IntPtr asking = GetForegroundWindow();
65
+ RECT r;
66
+ if (GetWindowRect(asking, out r)) owner.SetBounds(r.L + 48, r.T + 48, 1, 1);
67
+ else owner.StartPosition = FormStartPosition.CenterScreen;
68
+ owner.Show();
69
+ uint front = GetWindowThreadProcessId(asking, IntPtr.Zero), self = GetCurrentThreadId();
70
+ AttachThreadInput(self, front, true);
71
+ SetForegroundWindow(owner.Handle);
72
+ AttachThreadInput(self, front, false);
73
+ if (dialog.Show(owner.Handle) != 0) return null;
74
+ }
75
+ IShellItem picked;
76
+ dialog.GetResult(out picked);
77
+ string path;
78
+ picked.GetDisplayName(0x80058000, out path);
79
+ return path;
80
+ }
81
+ }
82
+ '@
83
+ $picked = [GuildFolderPicker]::Pick($env:AGENT_GUILD_PICK_START, $env:AGENT_GUILD_PICK_TITLE)
84
+ if ($picked) { [Console]::Out.Write($picked) }
@@ -0,0 +1,171 @@
1
+ // Issues, Actions runs and open pull requests of one repository, read with a
2
+ // signed-in account. Reading and editing stay shallow: GitHub's page is the full view.
3
+
4
+ import { nextLink, parseRepo } from './github.mjs';
5
+
6
+ const PAGE = 30;
7
+ const RUNNING = new Set(['queued', 'in_progress', 'waiting', 'requested', 'pending']);
8
+ const WEB = 'https://github.com';
9
+ const ISSUE_RE = /^[1-9]\d{0,9}$/;
10
+
11
+ function refusal(status, code, message) {
12
+ return Object.assign(new Error(message), { status, code });
13
+ }
14
+
15
+ /** A github.com page, or the fallback built from checked parts. */
16
+ export function githubUrl(value, fallback) {
17
+ try {
18
+ const url = new URL(value);
19
+ if (url.protocol === 'https:' && url.hostname === 'github.com') return url.href;
20
+ } catch { /* the fallback */ }
21
+ return fallback;
22
+ }
23
+
24
+ function text(value, max) {
25
+ return typeof value === 'string' ? value.slice(0, max) : '';
26
+ }
27
+
28
+ function time(value) {
29
+ return Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : null;
30
+ }
31
+
32
+ function loginOf(user) {
33
+ return typeof user?.login === 'string' && user.login.trim() ? user.login.trim().slice(0, 80) : 'unknown';
34
+ }
35
+
36
+ function cleanIssue(raw, fullName) {
37
+ const number = raw?.number;
38
+ if (!raw || typeof raw !== 'object' || !Number.isSafeInteger(number) || number < 1) return null;
39
+ if (typeof raw.title !== 'string' || !raw.title.trim()) return null;
40
+ return {
41
+ number,
42
+ title: raw.title.trim().slice(0, 256),
43
+ body: text(raw.body, 65536),
44
+ state: raw.state === 'closed' ? 'closed' : 'open',
45
+ user: loginOf(raw.user),
46
+ comments: Number.isSafeInteger(raw.comments) ? raw.comments : 0,
47
+ updatedAt: time(raw.updated_at),
48
+ url: githubUrl(raw.html_url, `${WEB}/${fullName}/issues/${number}`),
49
+ };
50
+ }
51
+
52
+ function cleanRun(raw, fullName) {
53
+ const id = raw?.id;
54
+ if (!raw || typeof raw !== 'object' || !Number.isSafeInteger(id) || id < 1) return null;
55
+ return {
56
+ id,
57
+ name: text(raw.name, 200) || text(raw.display_title, 200) || 'Workflow',
58
+ title: text(raw.display_title, 300) || text(raw.name, 300) || 'Workflow run',
59
+ branch: text(raw.head_branch, 200),
60
+ event: text(raw.event, 40),
61
+ status: typeof raw.status === 'string' ? raw.status.slice(0, 40) : 'unknown',
62
+ conclusion: typeof raw.conclusion === 'string' ? raw.conclusion.slice(0, 40) : null,
63
+ runNumber: Number.isSafeInteger(raw.run_number) ? raw.run_number : null,
64
+ updatedAt: time(raw.updated_at),
65
+ url: githubUrl(raw.html_url, `${WEB}/${fullName}/actions/runs/${id}`),
66
+ };
67
+ }
68
+
69
+ function cleanPull(raw, fullName) {
70
+ const number = raw?.number;
71
+ if (!raw || typeof raw !== 'object' || !Number.isSafeInteger(number) || number < 1) return null;
72
+ if (typeof raw.title !== 'string' || !raw.title.trim()) return null;
73
+ return {
74
+ number,
75
+ title: raw.title.trim().slice(0, 256),
76
+ draft: raw.draft === true,
77
+ user: loginOf(raw.user),
78
+ head: text(raw.head?.ref, 200),
79
+ base: text(raw.base?.ref, 200),
80
+ updatedAt: time(raw.updated_at),
81
+ url: githubUrl(raw.html_url, `${WEB}/${fullName}/pull/${number}`),
82
+ };
83
+ }
84
+
85
+ function checkTitle(title) {
86
+ if (typeof title !== 'string' || !title.trim()) throw refusal(400, 'bad_title', 'title must be a non-empty string');
87
+ if (title.trim().length > 256) throw refusal(400, 'bad_title', 'Keep the title to 256 characters or fewer.');
88
+ return title.trim();
89
+ }
90
+
91
+ function checkBody(body) {
92
+ if (body === undefined || body === null) return '';
93
+ if (typeof body !== 'string') throw refusal(400, 'bad_body', 'body must be a string');
94
+ if (body.length > 48000) throw refusal(400, 'bad_body', 'Keep the description to 48,000 characters or fewer, or edit it on GitHub.');
95
+ return body;
96
+ }
97
+
98
+ export function createViews(github) {
99
+ /** One request for the repository's account. GitHub's own refusals become answers the page can show as they are. */
100
+ async function call(accountId, owner, name, route, init) {
101
+ const account = github.account(accountId);
102
+ const repo = parseRepo(`${owner ?? ''}/${name ?? ''}`);
103
+ try {
104
+ const { body, res } = await github._apiJson(account, `/repos/${repo.owner}/${repo.name}${route}`, init);
105
+ return { body, res, repo };
106
+ } catch (err) {
107
+ if (err.limited) throw refusal(429, 'rate_limited', err.message);
108
+ if (err.github === 404) throw refusal(404, 'not_found', `GitHub could not find ${repo.fullName} for @${account.login}`);
109
+ if (err.github === 410) throw refusal(404, 'issues_disabled', `Issues are turned off for ${repo.fullName}`);
110
+ if (err.github === 403) throw refusal(403, 'forbidden', `GitHub did not let @${account.login} do that in ${repo.fullName}`);
111
+ if (err.github === 422) throw refusal(400, 'github_rejected', err.message);
112
+ throw err;
113
+ }
114
+ }
115
+
116
+ return {
117
+ async issues(accountId, owner, name, { state = 'open' } = {}) {
118
+ if (state !== 'open' && state !== 'closed' && state !== 'all') throw refusal(400, 'bad_state', 'state must be open, closed or all');
119
+ const { body, res, repo } = await call(accountId, owner, name, `/issues?state=${state}&per_page=${PAGE}&sort=updated&direction=desc`);
120
+ return {
121
+ issues: (Array.isArray(body) ? body : []).filter((item) => item && !item.pull_request).map((item) => cleanIssue(item, repo.fullName)).filter(Boolean),
122
+ truncated: Boolean(nextLink(res.headers.get('link'))),
123
+ url: `${WEB}/${repo.fullName}/issues`,
124
+ };
125
+ },
126
+
127
+ async createIssue(accountId, owner, name, { title, body } = {}) {
128
+ const payload = { title: checkTitle(title), body: checkBody(body) };
129
+ const { body: created, repo } = await call(accountId, owner, name, '/issues', { method: 'POST', body: JSON.stringify(payload) });
130
+ const issue = cleanIssue(created, repo.fullName);
131
+ if (!issue) throw refusal(502, 'github_error', 'GitHub did not describe the new issue');
132
+ return issue;
133
+ },
134
+
135
+ async updateIssue(accountId, owner, name, number, patch = {}) {
136
+ if (!ISSUE_RE.test(String(number ?? ''))) throw refusal(400, 'bad_issue', 'issue number must be a positive integer');
137
+ const payload = {};
138
+ if (patch.title !== undefined) payload.title = checkTitle(patch.title);
139
+ if (patch.body !== undefined) payload.body = checkBody(patch.body);
140
+ if (patch.state !== undefined) {
141
+ if (patch.state !== 'open' && patch.state !== 'closed') throw refusal(400, 'bad_state', 'state must be open or closed');
142
+ payload.state = patch.state;
143
+ }
144
+ if (Object.keys(payload).length === 0) throw refusal(400, 'bad_request', 'nothing to update');
145
+ const { body, repo } = await call(accountId, owner, name, `/issues/${Number(number)}`, { method: 'PATCH', body: JSON.stringify(payload) });
146
+ const issue = cleanIssue(body, repo.fullName);
147
+ if (!issue) throw refusal(502, 'github_error', 'GitHub did not describe the issue');
148
+ return issue;
149
+ },
150
+
151
+ async actions(accountId, owner, name) {
152
+ const { body, res, repo } = await call(accountId, owner, name, `/actions/runs?per_page=${PAGE}`);
153
+ const runs = (Array.isArray(body?.workflow_runs) ? body.workflow_runs : []).map((item) => cleanRun(item, repo.fullName)).filter(Boolean);
154
+ return {
155
+ runs,
156
+ running: runs.some((run) => RUNNING.has(run.status)),
157
+ truncated: Boolean(nextLink(res.headers.get('link'))),
158
+ url: `${WEB}/${repo.fullName}/actions`,
159
+ };
160
+ },
161
+
162
+ async pulls(accountId, owner, name) {
163
+ const { body, res, repo } = await call(accountId, owner, name, `/pulls?state=open&per_page=${PAGE}&sort=updated&direction=desc`);
164
+ return {
165
+ pulls: (Array.isArray(body) ? body : []).map((item) => cleanPull(item, repo.fullName)).filter(Boolean),
166
+ truncated: Boolean(nextLink(res.headers.get('link'))),
167
+ url: `${WEB}/${repo.fullName}/pulls`,
168
+ };
169
+ },
170
+ };
171
+ }
@@ -101,6 +101,34 @@ export function localState(target, fullName) {
101
101
  }
102
102
  }
103
103
 
104
+ /** The GitHub repository (lowercase owner/name) that `dir`, or the Git work tree holding it, has as its origin. */
105
+ export function folderOrigin(dir) {
106
+ for (let current = path.resolve(dir); ; current = path.dirname(current)) {
107
+ const dotGit = path.join(current, '.git');
108
+ let stat = null;
109
+ try { stat = fs.statSync(dotGit); } catch { /* look further up */ }
110
+ if (stat) {
111
+ try {
112
+ return remoteRepo(originUrl(fs.readFileSync(path.join(commonGitDir(dotGit, stat), 'config'), 'utf8')));
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+ if (path.dirname(current) === current) return null;
118
+ }
119
+ }
120
+
121
+ /** A worktree's .git is a file naming its own Git folder, whose `commondir` names the folder holding the shared config. */
122
+ function commonGitDir(dotGit, stat) {
123
+ if (stat.isDirectory()) return dotGit;
124
+ const named = /^gitdir:\s*(.+?)\s*$/m.exec(fs.readFileSync(dotGit, 'utf8'));
125
+ if (!named) throw new Error('no gitdir');
126
+ const gitDir = path.resolve(path.dirname(dotGit), named[1]);
127
+ let common = null;
128
+ try { common = fs.readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim(); } catch { /* not a worktree */ }
129
+ return common ? path.resolve(gitDir, common) : gitDir;
130
+ }
131
+
104
132
  /** Quote one word for the POSIX shell Git runs core.sshCommand with (Git for Windows ships one too). */
105
133
  export function shellQuote(word) {
106
134
  return `'${String(word).replaceAll('\'', '\'\\\'\'')}'`;
@@ -395,7 +423,7 @@ export class GitHub extends EventEmitter {
395
423
  if (!res.ok) {
396
424
  const body = await res.json().catch(() => null);
397
425
  const limited = (res.status === 403 || res.status === 429) && res.headers.get('x-ratelimit-remaining') === '0';
398
- throw refusal(502, 'github_error', limited ? 'GitHub API rate limit exceeded; try again later' : `GitHub answered HTTP ${res.status}${body?.message ? `: ${body.message}` : ''}`, { github: res.status });
426
+ throw refusal(502, 'github_error', limited ? 'GitHub API rate limit exceeded; try again later' : `GitHub answered HTTP ${res.status}${body?.message ? `: ${body.message}` : ''}`, { github: res.status, limited });
399
427
  }
400
428
  return { body: await res.json(), res };
401
429
  }
@@ -613,6 +641,33 @@ export class GitHub extends EventEmitter {
613
641
  };
614
642
  }
615
643
 
644
+ /** Every signed-in account's repositories in one list. One account's failure does not hide the others'. */
645
+ async allRepos({ refresh = false } = {}) {
646
+ const settled = await Promise.all(this.accounts.map(async (account) => {
647
+ try {
648
+ return { account, page: await this.repos(account.id, { refresh }) };
649
+ } catch (err) {
650
+ return { account, err };
651
+ }
652
+ }));
653
+ const repos = [];
654
+ const errors = [];
655
+ let truncated = false;
656
+ let oldest = null;
657
+ for (const { account, page, err } of settled) {
658
+ if (err) {
659
+ errors.push({ accountId: account.id, login: account.login, code: err.code || 'github_error', message: err.status ? err.message : 'Could not list repositories' });
660
+ continue;
661
+ }
662
+ truncated ||= page.truncated;
663
+ const at = Date.parse(page.fetchedAt);
664
+ if (oldest === null || at < oldest) oldest = at;
665
+ for (const { target, local, ...repo } of page.repos) repos.push({ accountId: account.id, login: account.login, ...repo });
666
+ }
667
+ repos.sort((a, b) => (Date.parse(b.pushedAt) || 0) - (Date.parse(a.pushedAt) || 0) || a.fullName.localeCompare(b.fullName) || a.accountId - b.accountId);
668
+ return { repos, truncated, errors, fetchedAt: oldest === null ? null : new Date(oldest).toISOString() };
669
+ }
670
+
616
671
  async _loadRepos(account) {
617
672
  const seen = new Map();
618
673
  let url = '/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member';
@@ -13,6 +13,7 @@ import { NewsFeed } from './news.mjs';
13
13
  import { Changelog } from './changelog.mjs';
14
14
  import { GitHub } from './github.mjs';
15
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,10 +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 = {} } = {}) {
43
+ // Validate before creating files, processes or timers, so a typo fails startup cleanly.
44
+ const remoteSettings = loadRemoteAccess(paths.remoteAccess);
42
45
  ensureDataDir();
43
46
  const token = loadOrCreateToken();
44
47
  const baseEnv = resolveBaseEnv();
48
+ const remoteAccess = new RemoteAccess({ file: paths.remoteAccess, env: baseEnv, loaded: remoteSettings, ...remoteOptions });
45
49
  const webDir = path.join(rootDir, 'web');
46
50
  // The hooks in examples/ call `agent-guild-report` by name; these shims
47
51
  // make that name resolve inside every session. Regenerated at each start
@@ -93,6 +97,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
93
97
  if (closing) return closing;
94
98
  console.log(`[manager] ${restart ? 'restarting' : 'stopping'} (${reason}); ending ${manager.sessions.size} session(s)`);
95
99
  clearInterval(versionTimer);
100
+ remoteAccess.close();
96
101
  github.close();
97
102
  removeRuntimeFile();
98
103
  // Sessions end before the API closes, and the last event says whether
@@ -115,9 +120,6 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
115
120
  return closing;
116
121
  };
117
122
 
118
- const extraOrigins = (process.env.AGENT_GUILD_ALLOWED_ORIGINS || '')
119
- .split(',').map((s) => s.trim()).filter(Boolean);
120
-
121
123
  api = createManagerServer({
122
124
  manager,
123
125
  registry,
@@ -133,10 +135,13 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
133
135
  webDir,
134
136
  version,
135
137
  selfUpdate,
136
- extraOrigins,
138
+ extraHosts: remoteSettings.config.access.hosts,
139
+ extraOrigins: remoteSettings.config.access.origins,
140
+ remoteAccess,
137
141
  launcher: launcherPath(),
138
142
  onShutdownRequest: ({ restart = false } = {}) => shutdown('requested via API', { restart }).then(() => process.exit(0)),
139
143
  });
144
+ remoteAccess.attach({ apply: (access) => api.setAccessPolicy(access), getTarget: () => api.url });
140
145
 
141
146
  try {
142
147
  await api.listen();
@@ -162,7 +167,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
162
167
  });
163
168
  console.log(`[manager] Agent Guild ${version} listening on ${api.url} (pid ${process.pid})`);
164
169
  refreshVersions();
165
- return { api, manager, registry, token, shutdown };
170
+ return { api, manager, registry, token, shutdown, remoteAccess };
166
171
  }
167
172
 
168
173
  function isEntryPoint() {