@openagents-org/agent-launcher 0.2.130 → 0.2.132

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.
@@ -0,0 +1,165 @@
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+
5
+ const DEFAULT_BASE_URL = 'https://api.github.com';
6
+ const USER_AGENT = '@openagents-org/agent-launcher';
7
+
8
+ /**
9
+ * Thin GitHub REST API client used by the launcher's GitHub integration.
10
+ *
11
+ * Auth is via a personal access token (classic or fine-grained) or GitHub
12
+ * App installation token. Each call accepts an explicit token so the caller
13
+ * can resolve it from the launcher's credentials store at request time.
14
+ */
15
+ class GitHubClient {
16
+ constructor(opts = {}) {
17
+ this.baseUrl = (opts.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, '');
18
+ }
19
+
20
+ /**
21
+ * Verify a token and return identity / rate-limit info.
22
+ * Mirrors `GET /user` + `GET /rate_limit`.
23
+ */
24
+ async probe(token) {
25
+ if (!token) throw new Error('Missing GitHub token');
26
+ const user = await this._get('/user', token);
27
+ const rate = await this._get('/rate_limit', token).catch(() => null);
28
+ return {
29
+ ok: true,
30
+ login: user.login,
31
+ name: user.name,
32
+ avatarUrl: user.avatar_url,
33
+ scopes: user.__scopes || [],
34
+ rate: rate ? rate.rate : null,
35
+ };
36
+ }
37
+
38
+ async getRepo(owner, name, token) {
39
+ return this._get(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, token);
40
+ }
41
+
42
+ /**
43
+ * @param {string} owner
44
+ * @param {string} name
45
+ * @param {{ state?: 'open'|'closed'|'all', perPage?: number, page?: number }} opts
46
+ * @param {string} token
47
+ */
48
+ async listIssues(owner, name, opts = {}, token) {
49
+ const state = opts.state || 'open';
50
+ const perPage = opts.perPage || 20;
51
+ const page = opts.page || 1;
52
+ // GitHub's /issues endpoint returns PRs too — filter to issue-only.
53
+ const items = await this._get(
54
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues?state=${state}&per_page=${perPage}&page=${page}`,
55
+ token,
56
+ );
57
+ return items.filter((i) => !i.pull_request);
58
+ }
59
+
60
+ async listPullRequests(owner, name, opts = {}, token) {
61
+ const state = opts.state || 'open';
62
+ const perPage = opts.perPage || 20;
63
+ const page = opts.page || 1;
64
+ return this._get(
65
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls?state=${state}&per_page=${perPage}&page=${page}`,
66
+ token,
67
+ );
68
+ }
69
+
70
+ async createIssueComment(owner, name, issueNumber, body, token) {
71
+ return this._post(
72
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${issueNumber}/comments`,
73
+ { body },
74
+ token,
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Parse a GitHub-ish input into { owner, name }.
80
+ * Accepts: "owner/name", "https://github.com/owner/name", "git@github.com:owner/name.git".
81
+ */
82
+ static parseRepo(input) {
83
+ if (!input) return null;
84
+ const v = String(input).trim();
85
+ if (!v) return null;
86
+ // SSH form: git@github.com:owner/name(.git)
87
+ const ssh = v.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/);
88
+ if (ssh) return { owner: ssh[1], name: ssh[2] };
89
+ // URL form
90
+ try {
91
+ const u = new URL(v);
92
+ if (/github\.com$/i.test(u.hostname)) {
93
+ const parts = u.pathname.replace(/^\//, '').replace(/\.git$/, '').split('/');
94
+ if (parts.length >= 2 && parts[0] && parts[1]) {
95
+ return { owner: parts[0], name: parts[1] };
96
+ }
97
+ }
98
+ } catch {
99
+ // not a URL — fall through
100
+ }
101
+ // Plain "owner/name"
102
+ const plain = v.match(/^([^/\s]+)\/([^/\s]+?)(?:\.git)?$/);
103
+ if (plain) return { owner: plain[1], name: plain[2] };
104
+ return null;
105
+ }
106
+
107
+ _headers(token, extra = {}) {
108
+ return {
109
+ Accept: 'application/vnd.github+json',
110
+ 'X-GitHub-Api-Version': '2022-11-28',
111
+ 'User-Agent': USER_AGENT,
112
+ Authorization: `Bearer ${token}`,
113
+ ...extra,
114
+ };
115
+ }
116
+
117
+ _get(urlPath, token, timeout = 20000) {
118
+ return this._request('GET', urlPath, null, token, timeout);
119
+ }
120
+
121
+ _post(urlPath, body, token, timeout = 30000) {
122
+ return this._request('POST', urlPath, body, token, timeout);
123
+ }
124
+
125
+ _request(method, urlPath, body, token, timeout) {
126
+ const fullUrl = this.baseUrl + urlPath;
127
+ const jsonBody = body == null ? null : JSON.stringify(body);
128
+ const headers = this._headers(token);
129
+ if (jsonBody != null) headers['Content-Type'] = 'application/json';
130
+
131
+ return new Promise((resolve, reject) => {
132
+ const req = https.request(fullUrl, { method, headers, timeout }, (res) => {
133
+ let data = '';
134
+ res.on('data', (chunk) => { data += chunk; });
135
+ res.on('end', () => {
136
+ const status = res.statusCode || 0;
137
+ let parsed = null;
138
+ try { parsed = data ? JSON.parse(data) : null; } catch { parsed = null; }
139
+ if (status >= 400) {
140
+ const msg = (parsed && (parsed.message || parsed.error)) || `HTTP ${status}`;
141
+ const err = new Error(`GitHub ${method} ${urlPath} failed: ${msg}`);
142
+ err.status = status;
143
+ err.body = parsed;
144
+ return reject(err);
145
+ }
146
+ // Attach OAuth scopes for /user probe convenience.
147
+ if (parsed && method === 'GET' && urlPath === '/user') {
148
+ const scopes = res.headers['x-oauth-scopes'];
149
+ if (typeof scopes === 'string') {
150
+ parsed.__scopes = scopes.split(',').map((s) => s.trim()).filter(Boolean);
151
+ }
152
+ }
153
+ resolve(parsed);
154
+ });
155
+ });
156
+
157
+ req.on('error', reject);
158
+ req.on('timeout', () => { req.destroy(); reject(new Error('GitHub request timed out')); });
159
+ if (jsonBody != null) req.write(jsonBody);
160
+ req.end();
161
+ });
162
+ }
163
+ }
164
+
165
+ module.exports = { GitHubClient };
package/src/index.js CHANGED
@@ -6,6 +6,7 @@ const { Registry } = require('./registry');
6
6
  const { Installer } = require('./installer');
7
7
  const { Daemon } = require('./daemon');
8
8
  const { WorkspaceClient } = require('./workspace-client');
9
+ const { GitHubClient } = require('./github-client');
9
10
 
10
11
  /**
11
12
  * Main entry point for the agent-connector library.
@@ -125,6 +126,17 @@ class AgentConnector {
125
126
  return { success: true };
126
127
  }
127
128
 
129
+ /**
130
+ * Delete the entire type-level env file (~/.openagents/env/<type>.env).
131
+ * Used by the launcher's "wipe saved credentials" path on uninstall so
132
+ * sensitive values (API keys) don't survive a reinstall as surprise
133
+ * pre-filled defaults in the setup wizard.
134
+ */
135
+ deleteAgentEnv(agentType) {
136
+ this.env.delete(agentType);
137
+ return { success: true };
138
+ }
139
+
128
140
  saveAgentInstanceEnv(agentName, env) {
129
141
  const agent = this.config.getAgent(agentName);
130
142
  if (!agent) throw new Error(`Agent '${agentName}' not found`);
@@ -274,4 +286,4 @@ class AgentConnector {
274
286
  const adapters = require('./adapters');
275
287
 
276
288
  const paths = require('./paths');
277
- module.exports = { AgentConnector, Daemon, WorkspaceClient, adapters, paths };
289
+ module.exports = { AgentConnector, Daemon, WorkspaceClient, GitHubClient, adapters, paths };
package/src/installer.js CHANGED
@@ -433,8 +433,15 @@ class Installer {
433
433
  if (proc.stdout) proc.stdout.setEncoding('utf-8');
434
434
  if (proc.stderr) proc.stderr.setEncoding('utf-8');
435
435
 
436
- if (proc.stdout) proc.stdout.on('data', (d) => { if (onData) onData(d); });
437
- if (proc.stderr) proc.stderr.on('data', (d) => { if (onData) onData(d); });
436
+ let outputTail = '';
437
+ const captureOutput = (d) => {
438
+ const text = String(d);
439
+ outputTail = (outputTail + text).slice(-4000);
440
+ if (onData) onData(text);
441
+ };
442
+
443
+ if (proc.stdout) proc.stdout.on('data', captureOutput);
444
+ if (proc.stderr) proc.stderr.on('data', captureOutput);
438
445
 
439
446
  proc.on('error', (err) => reject(err));
440
447
  proc.on('close', (code) => {
@@ -443,7 +450,21 @@ class Installer {
443
450
  if (onData) onData(`\nDone! ${agentType} is now installed.\n`);
444
451
  resolve({ success: true, command: cmd });
445
452
  } else {
446
- const msg = `Install failed with exit code ${code}`;
453
+ // A partial install can leave a placeholder stub at
454
+ // bin/<binary>.exe and npm-generated cmd-shims under
455
+ // node_modules/.bin/<name>{,.cmd,.ps1} that point to it. On
456
+ // Windows, anything that later tries to run the binary (a health
457
+ // check, a shell open) triggers a "this app can't run on your PC"
458
+ // dialog because the stub isn't a real PE executable. Sweep them
459
+ // up so the next install (or uninstall+reinstall) starts clean.
460
+ try {
461
+ this._cleanStaleShims(agentType);
462
+ this._cleanStubBinary(agentType);
463
+ } catch {}
464
+ const tail = outputTail.trim();
465
+ const msg = tail
466
+ ? `Install failed with exit code ${code}\nCommand: ${cmd}\n\n${tail}`
467
+ : `Install failed with exit code ${code}\nCommand: ${cmd}`;
447
468
  if (onData) onData(`\n${msg}\n`);
448
469
  reject(new Error(msg));
449
470
  }
@@ -451,6 +472,35 @@ class Installer {
451
472
  });
452
473
  }
453
474
 
475
+ /**
476
+ * Remove the placeholder bin/<binary>.exe (and the npm package directory
477
+ * if it's effectively empty) left over from a failed postinstall.
478
+ */
479
+ _cleanStubBinary(agentType) {
480
+ const entry = this.registry.getEntry(agentType);
481
+ const install = entry && entry.install;
482
+ if (!install) return;
483
+ const binary = install.binary || agentType;
484
+ const npmPkg = install.npm_package
485
+ || (() => {
486
+ const cmd = this._getInstallCommand(install);
487
+ if (!cmd || !cmd.includes('npm install')) return null;
488
+ const m = cmd.match(/npm install\s+(?:-g\s+)?(@?[\w-]+(?:\/[\w-]+)?)(?:@\S*)?$/);
489
+ return m ? m[1] : null;
490
+ })();
491
+ if (!npmPkg) return;
492
+
493
+ const prefix = getRuntimePrefix(agentType);
494
+ const pkgDir = path.join(prefix, 'node_modules', npmPkg);
495
+ const stubDir = path.join(pkgDir, 'bin');
496
+ for (const ext of ['', '.exe', '.cmd', '.ps1']) {
497
+ try {
498
+ const f = path.join(stubDir, binary + ext);
499
+ if (fs.existsSync(f) && fs.statSync(f).size < 4096) fs.unlinkSync(f);
500
+ } catch {}
501
+ }
502
+ }
503
+
454
504
  /**
455
505
  * Uninstall an agent runtime.
456
506
  * @returns {Promise<{success: boolean, output: string}>}
@@ -566,7 +616,7 @@ class Installer {
566
616
  */
567
617
  _getInstallCommand(installCfg) {
568
618
  const plat = Installer.platform();
569
- return installCfg[plat] || installCfg.command || null;
619
+ return installCfg[plat] || installCfg.command || installCfg.npm || null;
570
620
  }
571
621
 
572
622
  /**
@@ -608,7 +658,14 @@ class Installer {
608
658
  _whichBinary(agentType) {
609
659
  const entry = this.registry.getEntry(agentType);
610
660
  const binary = entry && entry.install ? entry.install.binary : agentType;
611
- return whichBinary(binary);
661
+ const aliases = entry && entry.install && Array.isArray(entry.install.binary_aliases)
662
+ ? entry.install.binary_aliases
663
+ : [];
664
+ for (const candidate of [binary, ...aliases]) {
665
+ const found = whichBinary(candidate);
666
+ if (found) return found;
667
+ }
668
+ return null;
612
669
  }
613
670
 
614
671
  // -- Markers --
@@ -680,28 +737,58 @@ class Installer {
680
737
  const extraDirs = [];
681
738
  try { extraDirs.push(path.dirname(process.execPath)); } catch {}
682
739
 
683
- // Check for bundled Node.js in ~/.openagents/nodejs/
740
+ // Whether a usable `node` is already reachable WITHOUT the bundled
741
+ // standalone node.exe. We must avoid shadowing a working system node with
742
+ // the launcher's bundled exe: that file is an unsigned standalone
743
+ // node.exe and on some Windows machines Defender/SmartScreen blocks
744
+ // CreateProcess on it, which surfaces as `cmd: 拒绝访问` ("Access
745
+ // Denied") when npm runs `node install.cjs` for packages like
746
+ // @anthropic-ai/claude-code, plus a "this app can't run on your PC"
747
+ // dialog.
748
+ const bundledDir = path.join(this.configDir, 'nodejs');
749
+ const hasSystemNode = this._hasSystemNode(bundledDir);
750
+
684
751
  if (process.platform === 'win32') {
685
- try {
686
- const bundledDir = path.join(this.configDir, 'nodejs');
687
- if (fs.existsSync(bundledDir)) {
688
- // Node.js may be directly in nodejs/ (launcher symlink style) or in nodejs/node-v*/
689
- if (fs.existsSync(path.join(bundledDir, 'node.exe'))) {
690
- extraDirs.push(bundledDir);
691
- }
692
- const entries = fs.readdirSync(bundledDir).filter(e => e.startsWith('node-'));
693
- if (entries.length > 0) {
694
- extraDirs.push(path.join(bundledDir, entries[0]));
695
- }
696
- }
697
- } catch {}
698
752
  const appData = env.APPDATA || '';
699
753
  if (appData) extraDirs.push(path.join(appData, 'npm'));
700
754
  extraDirs.push(env.ProgramFiles ? path.join(env.ProgramFiles, 'nodejs') : 'C:\\Program Files\\nodejs');
701
755
  extraDirs.push(env.SystemRoot ? path.join(env.SystemRoot, 'System32') : 'C:\\Windows\\System32');
702
756
  extraDirs.push(env.ProgramFiles ? path.join(env.ProgramFiles, 'Git', 'cmd') : 'C:\\Program Files\\Git\\cmd');
757
+
758
+ // Bundled node — fallback only. Skip if system node works, otherwise
759
+ // verify the bundled exe can actually launch before exposing it.
760
+ if (!hasSystemNode) {
761
+ try {
762
+ if (fs.existsSync(bundledDir)) {
763
+ const directExe = path.join(bundledDir, 'node.exe');
764
+ if (fs.existsSync(directExe) && this._canExecute(directExe)) {
765
+ extraDirs.unshift(bundledDir);
766
+ }
767
+ for (const entry of fs.readdirSync(bundledDir).filter(e => e.startsWith('node-'))) {
768
+ const nested = path.join(bundledDir, entry);
769
+ if (fs.existsSync(path.join(nested, 'node.exe')) && this._canExecute(path.join(nested, 'node.exe'))) {
770
+ extraDirs.unshift(nested);
771
+ }
772
+ }
773
+ }
774
+ } catch {}
775
+ }
703
776
  } else {
704
777
  extraDirs.push('/usr/local/bin', '/opt/homebrew/bin');
778
+ if (!hasSystemNode) {
779
+ try {
780
+ const candidates = [
781
+ path.join(bundledDir, 'bin', 'node'),
782
+ path.join(bundledDir, 'node'),
783
+ ];
784
+ for (const bin of candidates) {
785
+ if (fs.existsSync(bin) && this._canExecute(bin)) {
786
+ extraDirs.unshift(path.dirname(bin));
787
+ break;
788
+ }
789
+ }
790
+ } catch {}
791
+ }
705
792
  }
706
793
  for (const d of extraDirs) {
707
794
  if (d && !(env.PATH || '').includes(d)) {
@@ -711,6 +798,51 @@ class Installer {
711
798
  return env;
712
799
  }
713
800
 
801
+ /**
802
+ * True if `node` exists outside the bundled launcher directory. Uses raw
803
+ * process.env.PATH (no enhancement) so we don't accidentally detect the
804
+ * bundled node and conclude that "system node exists".
805
+ */
806
+ _hasSystemNode(bundledDir) {
807
+ try {
808
+ const { execFileSync } = require('child_process');
809
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
810
+ const args = process.platform === 'win32' ? ['node'] : ['node'];
811
+ const out = execFileSync(cmd, args, {
812
+ encoding: 'utf-8',
813
+ stdio: ['ignore', 'pipe', 'ignore'],
814
+ timeout: 5000,
815
+ windowsHide: true,
816
+ env: { ...process.env },
817
+ });
818
+ const prefix = (bundledDir || '').toLowerCase();
819
+ for (const line of out.split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
820
+ if (!prefix || !line.toLowerCase().startsWith(prefix)) return true;
821
+ }
822
+ return false;
823
+ } catch {
824
+ return false;
825
+ }
826
+ }
827
+
828
+ /**
829
+ * Smoke-test a node binary by running `--version`. Returns false on any
830
+ * failure (missing exe, blocked by AV, arch mismatch, signature policy).
831
+ */
832
+ _canExecute(binaryPath) {
833
+ try {
834
+ const { spawnSync } = require('child_process');
835
+ const r = spawnSync(binaryPath, ['--version'], {
836
+ timeout: 5000,
837
+ windowsHide: true,
838
+ stdio: ['ignore', 'pipe', 'pipe'],
839
+ });
840
+ return r.status === 0 && !r.error;
841
+ } catch {
842
+ return false;
843
+ }
844
+ }
845
+
714
846
  /**
715
847
  * Check if Node.js/npm is available on the system.
716
848
  */