@openagents-org/agent-launcher 0.2.143 → 0.2.146
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/package.json +1 -1
- package/registry.json +274 -66
- package/src/adapters/aider.js +634 -0
- package/src/adapters/amp.js +477 -0
- package/src/adapters/base.js +112 -115
- package/src/adapters/claude.js +36 -34
- package/src/adapters/cline-stream.js +632 -0
- package/src/adapters/cline.js +914 -0
- package/src/adapters/codex.js +14 -16
- package/src/adapters/copilot-stream-parser.js +380 -0
- package/src/adapters/copilot.js +796 -0
- package/src/adapters/cursor.js +9 -15
- package/src/adapters/gemini.js +44 -12
- package/src/adapters/goose-stream.js +268 -0
- package/src/adapters/goose.js +649 -0
- package/src/adapters/health-status.js +166 -0
- package/src/adapters/hermes.js +8 -17
- package/src/adapters/index.js +16 -1
- package/src/adapters/nanoclaw-bridge.js +264 -0
- package/src/adapters/nanoclaw-channel-install.js +411 -0
- package/src/adapters/nanoclaw-control.js +491 -0
- package/src/adapters/nanoclaw-delivery-store.js +0 -0
- package/src/adapters/nanoclaw-protocol.js +349 -0
- package/src/adapters/nanoclaw.js +661 -9
- package/src/adapters/opencode.js +541 -75
- package/src/adapters/workspace-prompt.js +0 -79
- package/src/agent-rows.js +13 -1
- package/src/auth-guidance.js +98 -0
- package/src/cli.js +36 -7
- package/src/config.js +8 -2
- package/src/daemon.js +165 -51
- package/src/installer.js +572 -10
- package/src/paths.js +125 -2
- package/src/tui.js +29 -2
- package/src/update-check.js +26 -14
- package/src/workspace-client.js +31 -29
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Copilot CLI adapter for OpenAgents workspace.
|
|
3
|
+
*
|
|
4
|
+
* Bridges the OFFICIAL GitHub Copilot CLI — executable `copilot`, npm package
|
|
5
|
+
* `@github/copilot` — to an OpenAgents workspace. This is NOT the retired
|
|
6
|
+
* `gh copilot` GitHub CLI extension; this adapter never invokes `gh`.
|
|
7
|
+
*
|
|
8
|
+
* Per incoming workspace message it spawns one `copilot -p <prompt>
|
|
9
|
+
* --output-format=json` subprocess (cwd = the agent's working directory),
|
|
10
|
+
* parses the JSONL event stream via the standalone pure parser
|
|
11
|
+
* (./copilot-stream-parser), maps events onto workspace messages, persists the
|
|
12
|
+
* real Copilot session id per channel for resume, and can interrupt + clean up
|
|
13
|
+
* the whole process tree on demand.
|
|
14
|
+
*
|
|
15
|
+
* Design notes specific to Copilot CLI (do not copy Cline/Codex semantics
|
|
16
|
+
* blindly):
|
|
17
|
+
* • Auth is GitHub-based (env tokens / gh / keychain OAuth) — never read or
|
|
18
|
+
* log tokens; the launcher/installer reports auth state, the CLI's own run
|
|
19
|
+
* result is the final authority.
|
|
20
|
+
* • Permissions are explicit and least-privilege: scoped to the working dir
|
|
21
|
+
* via --add-dir, interactive prompts disabled (--no-ask-user) since the
|
|
22
|
+
* workspace cannot answer them. We never default to --allow-all / --yolo /
|
|
23
|
+
* --allow-all-paths / --allow-url.
|
|
24
|
+
* • plan mode → analysis only (--plan, no write tools); act mode → controlled
|
|
25
|
+
* read/write/shell within the working dir.
|
|
26
|
+
*
|
|
27
|
+
* ── Verification status ────────────────────────────────────────────────────
|
|
28
|
+
* The concrete CLI flags and JSONL `type` strings used here follow the
|
|
29
|
+
* documented `@github/copilot` interface and are CENTRALIZED (flags in
|
|
30
|
+
* `_buildArgs`, the JSONL schema in copilot-stream-parser). They must be
|
|
31
|
+
* confirmed against the locally installed build; see
|
|
32
|
+
* docs/agents/github-copilot-cli.md. Unknown events degrade gracefully rather
|
|
33
|
+
* than crashing a task.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
'use strict';
|
|
37
|
+
|
|
38
|
+
const fs = require('fs');
|
|
39
|
+
const os = require('os');
|
|
40
|
+
const path = require('path');
|
|
41
|
+
const { execSync, execFileSync, spawn } = require('child_process');
|
|
42
|
+
|
|
43
|
+
const BaseAdapter = require('./base');
|
|
44
|
+
const { buildOpenclawSystemPrompt } = require('./workspace-prompt');
|
|
45
|
+
const { whichBinary, whereBinary, getEnhancedEnv, defaultAgentWorkdir } = require('../paths');
|
|
46
|
+
const { compareVersions } = require('../installer');
|
|
47
|
+
const { CopilotStreamParser, redactSensitive } = require('./copilot-stream-parser');
|
|
48
|
+
|
|
49
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
50
|
+
|
|
51
|
+
// Minimum Copilot CLI version the adapter can drive. Kept in sync with
|
|
52
|
+
// registry.json `install.min_version`. Basis: the GA 1.0 line is the first to
|
|
53
|
+
// stably provide everything the adapter relies on — `--output-format=json`
|
|
54
|
+
// (JSONL; introduced ~0.0.422), non-interactive `-p` with `--no-ask-user`,
|
|
55
|
+
// granular `--allow-tool`/`--add-dir`, `--resume`/`--name` session control, and
|
|
56
|
+
// `--secret-env-vars`/`--no-remote`. Verified end-to-end against 1.0.63.
|
|
57
|
+
const MIN_VERSION = '1.0.0';
|
|
58
|
+
|
|
59
|
+
// Token-bearing env var NAMES Copilot reads (verified precedence via
|
|
60
|
+
// `copilot help environment`): COPILOT_GITHUB_TOKEN > GH_TOKEN > GITHUB_TOKEN.
|
|
61
|
+
// Plus OpenAgents' own workspace token. We pass the NAMES (never values) to
|
|
62
|
+
// `--secret-env-vars` so the CLI strips/redacts them, and use the list to scrub
|
|
63
|
+
// our own diagnostics. We never log their VALUES.
|
|
64
|
+
const SECRET_ENV_VARS = [
|
|
65
|
+
'COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN', 'OA_WORKSPACE_TOKEN',
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
// Tool identifiers granted in act mode (least-privilege). VERIFIED against
|
|
69
|
+
// `copilot help permissions` (v1.0.63): `shell` (all shell commands) and `write`
|
|
70
|
+
// (file create/modify). Paths are scoped via --add-dir; we deliberately do NOT
|
|
71
|
+
// enable network/URL or all-paths access by default.
|
|
72
|
+
const ACT_ALLOW_TOOLS = ['shell', 'write'];
|
|
73
|
+
|
|
74
|
+
// How long to wait for a turn before giving up (no output / hung CLI).
|
|
75
|
+
const TURN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
76
|
+
|
|
77
|
+
class CopilotAdapter extends BaseAdapter {
|
|
78
|
+
/**
|
|
79
|
+
* @param {object} opts - BaseAdapter opts plus:
|
|
80
|
+
* @param {Set} [opts.disabledModules]
|
|
81
|
+
*/
|
|
82
|
+
constructor(opts) {
|
|
83
|
+
super(opts);
|
|
84
|
+
this.disabledModules = opts.disabledModules || new Set();
|
|
85
|
+
|
|
86
|
+
const env = this.agentEnv || process.env;
|
|
87
|
+
// Official model env var (verified via `copilot help environment`).
|
|
88
|
+
this._model = env.COPILOT_MODEL || '';
|
|
89
|
+
this._versionGate = null; // cached { version, compatible } from --version
|
|
90
|
+
|
|
91
|
+
// Per-channel real Copilot session reference (id or stable name) + live procs
|
|
92
|
+
this._channelSessions = {};
|
|
93
|
+
this._channelProcesses = {};
|
|
94
|
+
this._stoppingChannels = new Set();
|
|
95
|
+
this._sessionsFile = path.join(
|
|
96
|
+
os.homedir(), '.openagents', 'sessions',
|
|
97
|
+
`${this.workspaceId}_${this.agentName}_copilot.json`,
|
|
98
|
+
);
|
|
99
|
+
this._loadSessions();
|
|
100
|
+
|
|
101
|
+
this._copilotBin = this._findCopilotBinary();
|
|
102
|
+
if (this._copilotBin) {
|
|
103
|
+
this._log(`Copilot CLI: ${this._copilotBin}`);
|
|
104
|
+
} else {
|
|
105
|
+
this._log('Warning: copilot CLI not found (install: npm install -g @github/copilot)');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ------------------------------------------------------------------
|
|
110
|
+
// Session persistence (per-channel Copilot session reference)
|
|
111
|
+
// ------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
_loadSessions() {
|
|
114
|
+
try {
|
|
115
|
+
if (fs.existsSync(this._sessionsFile)) {
|
|
116
|
+
const data = JSON.parse(fs.readFileSync(this._sessionsFile, 'utf-8'));
|
|
117
|
+
if (data && typeof data === 'object') {
|
|
118
|
+
Object.assign(this._channelSessions, data);
|
|
119
|
+
this._log(`Loaded ${Object.keys(data).length} session(s)`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
this._log('Could not load sessions file, starting fresh');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_saveSessions() {
|
|
128
|
+
try {
|
|
129
|
+
fs.mkdirSync(path.dirname(this._sessionsFile), { recursive: true });
|
|
130
|
+
fs.writeFileSync(this._sessionsFile, JSON.stringify(this._channelSessions));
|
|
131
|
+
} catch {}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A stable, working-dir-bound session name for a channel. Binding the working
|
|
136
|
+
* directory into the name prevents a session created for one project from
|
|
137
|
+
* being resumed against another (the spec's "Session 与 working directory
|
|
138
|
+
* 绑定" / no cross-project resume requirement).
|
|
139
|
+
*/
|
|
140
|
+
_stableSessionName(channel) {
|
|
141
|
+
const wd = this.workingDir || defaultAgentWorkdir(this.agentName);
|
|
142
|
+
const hash = require('crypto').createHash('sha1')
|
|
143
|
+
.update(`${wd} ${this.workspaceId} ${channel}`).digest('hex').slice(0, 12);
|
|
144
|
+
return `openagents-${hash}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ------------------------------------------------------------------
|
|
148
|
+
// Binary discovery (multi-tier; never resolves the retired `gh` extension)
|
|
149
|
+
// ------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
_findCopilotBinary() {
|
|
152
|
+
const home = os.homedir();
|
|
153
|
+
// Windows: npm shim is copilot.cmd; a winget/standalone install is copilot.exe.
|
|
154
|
+
const exts = IS_WINDOWS ? ['.cmd', '.exe', ''] : [''];
|
|
155
|
+
const named = (dir) => {
|
|
156
|
+
for (const e of exts) {
|
|
157
|
+
const c = path.join(dir, `copilot${e}`);
|
|
158
|
+
try { if (fs.existsSync(c)) return c; } catch {}
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Tier 0: isolated runtime prefix (~/.openagents/runtimes/copilot/.bin)
|
|
164
|
+
let hit = named(path.join(home, '.openagents', 'runtimes', 'copilot', 'node_modules', '.bin'));
|
|
165
|
+
if (hit) return hit;
|
|
166
|
+
// Tier 0b: legacy portable npm prefix
|
|
167
|
+
hit = named(path.join(home, '.openagents', 'nodejs', 'node_modules', '.bin'));
|
|
168
|
+
if (hit) return hit;
|
|
169
|
+
|
|
170
|
+
// Tier 1: PATH search via a codepage-safe lookup (whereBinary forces UTF-8
|
|
171
|
+
// output + verifies existence so a non-ASCII/Chinese username isn't mangled
|
|
172
|
+
// into an ENOENT). Uses the enriched env (matches launcher/daemon PATH).
|
|
173
|
+
const viaWhere = whereBinary('copilot');
|
|
174
|
+
if (viaWhere) return viaWhere;
|
|
175
|
+
|
|
176
|
+
// Tier 2: next to the current Node interpreter (npm global)
|
|
177
|
+
hit = named(path.dirname(process.execPath));
|
|
178
|
+
if (hit) return hit;
|
|
179
|
+
|
|
180
|
+
// Tier 3: npm global prefix
|
|
181
|
+
try {
|
|
182
|
+
const npmPrefix = execSync('npm config get prefix', {
|
|
183
|
+
encoding: 'utf-8', timeout: 5000, windowsHide: true,
|
|
184
|
+
}).trim();
|
|
185
|
+
if (npmPrefix) {
|
|
186
|
+
hit = named(npmPrefix) || named(path.join(npmPrefix, 'bin'));
|
|
187
|
+
if (hit) return hit;
|
|
188
|
+
}
|
|
189
|
+
} catch {}
|
|
190
|
+
|
|
191
|
+
// Tier 4: common locations (Homebrew, user bins, winget links)
|
|
192
|
+
const candidates = IS_WINDOWS
|
|
193
|
+
? [path.join(process.env.APPDATA || '', 'npm'),
|
|
194
|
+
path.join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WinGet', 'Links')]
|
|
195
|
+
: ['/opt/homebrew/bin', '/usr/local/bin',
|
|
196
|
+
path.join(home, '.local', 'bin'), path.join(home, '.npm-global', 'bin')];
|
|
197
|
+
for (const dir of candidates) {
|
|
198
|
+
hit = named(dir);
|
|
199
|
+
if (hit) return hit;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Tier 5: deep cross-platform PATH scan (nvm/fnm/volta/etc.)
|
|
203
|
+
return whichBinary('copilot');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Resolve [file, prefixArgs] for spawning. On Windows an npm `.cmd` shim is
|
|
208
|
+
* resolved to `[node, entry.js]` so we can spawn shell:false and keep every
|
|
209
|
+
* argv element (notably the prompt) literal — no cmd.exe re-parsing, so no
|
|
210
|
+
* quoting/injection hazard from special characters in the prompt. A native
|
|
211
|
+
* `.exe` (winget/standalone) is spawned directly. On Unix the binary is spawned
|
|
212
|
+
* directly. Returns null if resolution is impossible.
|
|
213
|
+
*/
|
|
214
|
+
_resolveExec(bin, isWindows = IS_WINDOWS) {
|
|
215
|
+
const lower = bin.toLowerCase();
|
|
216
|
+
|
|
217
|
+
// A direct JS entry must run via `node` on EVERY platform. Windows cannot
|
|
218
|
+
// spawn a `.js` (no shebang support, not a PE → `spawn UNKNOWN`); on Unix a
|
|
219
|
+
// shebang would work, but routing through node is equivalent and keeps
|
|
220
|
+
// behaviour identical cross-platform. This also covers test mocks and any
|
|
221
|
+
// install whose bin resolves straight to a JS file.
|
|
222
|
+
if (lower.endsWith('.js') || lower.endsWith('.cjs') || lower.endsWith('.mjs')) {
|
|
223
|
+
return [this._findNodeBin(), bin];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!isWindows) return [bin];
|
|
227
|
+
|
|
228
|
+
// Native executable — spawn directly (shell:false keeps argv literal).
|
|
229
|
+
if (lower.endsWith('.exe')) return [bin];
|
|
230
|
+
|
|
231
|
+
// npm/batch shim: resolve to its real target so we spawn shell:false and
|
|
232
|
+
// every argv element (notably the prompt) stays literal — no cmd.exe
|
|
233
|
+
// re-parsing, hence no quoting/injection hazard.
|
|
234
|
+
if (lower.endsWith('.cmd') || lower.endsWith('.bat')) {
|
|
235
|
+
try {
|
|
236
|
+
const content = fs.readFileSync(bin, 'utf-8');
|
|
237
|
+
const m = content.match(/%dp0%\\([^\s"*?]+\.[cm]?js)/i);
|
|
238
|
+
if (m) {
|
|
239
|
+
return [this._findNodeBin(), path.resolve(path.dirname(bin), m[1])];
|
|
240
|
+
}
|
|
241
|
+
const exe = content.match(/%dp0%\\([^\s"*?]+\.exe)/i);
|
|
242
|
+
if (exe) return [path.resolve(path.dirname(bin), exe[1])];
|
|
243
|
+
} catch {}
|
|
244
|
+
// Last resort: let cmd.exe run the shim. Args still travel as a literal
|
|
245
|
+
// argv array via spawn (shell:false), not a concatenated string.
|
|
246
|
+
return ['cmd.exe', '/c', bin];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// No extension (e.g. a Unix-style shim copied to Windows) — spawn directly.
|
|
250
|
+
return [bin];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
_findNodeBin() {
|
|
254
|
+
const home = os.homedir();
|
|
255
|
+
const candidates = IS_WINDOWS
|
|
256
|
+
? [path.join(home, '.openagents', 'nodejs', 'node.exe')]
|
|
257
|
+
: [path.join(home, '.openagents', 'nodejs', 'bin', 'node'),
|
|
258
|
+
path.join(home, '.openagents', 'nodejs', 'node')];
|
|
259
|
+
for (const c of candidates) { try { if (fs.existsSync(c)) return c; } catch {} }
|
|
260
|
+
return process.execPath || 'node';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ------------------------------------------------------------------
|
|
264
|
+
// Command construction (argv array — never a shell string)
|
|
265
|
+
// ------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Build the argument vector (excluding the executable). The prompt is always a
|
|
269
|
+
* single discrete argv element. Permissions are least-privilege and scoped to
|
|
270
|
+
* the working directory.
|
|
271
|
+
*/
|
|
272
|
+
_buildArgs(prompt, channel, { skipResume = false } = {}) {
|
|
273
|
+
const wd = this.workingDir || defaultAgentWorkdir(this.agentName);
|
|
274
|
+
// Required-value flags take the space form; optional-value/variadic flags
|
|
275
|
+
// (--resume, --secret-env-vars, --allow-tool — declared `[=value]` /
|
|
276
|
+
// `[=value...]` in `copilot --help` v1.0.63) MUST use the `=` form so the
|
|
277
|
+
// next token isn't mistaken for a positional argument.
|
|
278
|
+
const args = ['-p', prompt, '--output-format', 'json', '--stream', 'on'];
|
|
279
|
+
|
|
280
|
+
if (this._model) args.push('--model', this._model);
|
|
281
|
+
|
|
282
|
+
// Scope filesystem access to the working dir only (no --allow-all-paths).
|
|
283
|
+
args.push('--add-dir', wd);
|
|
284
|
+
|
|
285
|
+
// Privacy: do not let the session be remote-controlled from GitHub web/mobile
|
|
286
|
+
// (verified flag `--no-remote`). We never pass --share / --share-gist.
|
|
287
|
+
args.push('--no-remote');
|
|
288
|
+
|
|
289
|
+
// The workspace cannot service interactive prompts, so disable them and
|
|
290
|
+
// pre-authorize a least-privilege tool set instead of --allow-all/--yolo.
|
|
291
|
+
// Tool IDs `shell` and `write` are verified against `copilot help permissions`.
|
|
292
|
+
args.push('--no-ask-user');
|
|
293
|
+
if (this._mode === 'plan') {
|
|
294
|
+
// Analysis/planning only — do not grant write capability.
|
|
295
|
+
args.push('--plan');
|
|
296
|
+
} else {
|
|
297
|
+
for (const t of ACT_ALLOW_TOOLS) args.push(`--allow-tool=${t}`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Defense-in-depth: tell Copilot which env-var NAMES to redact from its own
|
|
301
|
+
// output (NEVER the values). Only list vars actually present in the child
|
|
302
|
+
// env so we don't emit a meaningless argument.
|
|
303
|
+
const env = this.agentEnv || process.env;
|
|
304
|
+
const presentSecrets = SECRET_ENV_VARS.filter((k) => env[k]);
|
|
305
|
+
if (presentSecrets.length) args.push(`--secret-env-vars=${presentSecrets.join(',')}`);
|
|
306
|
+
|
|
307
|
+
// Resume the channel's prior session when we have a reference, else seed a
|
|
308
|
+
// stable, working-dir-bound name we can resume by (verified: --resume matches
|
|
309
|
+
// session id / task id / id-prefix / name, exact case-insensitive).
|
|
310
|
+
const ref = this._channelSessions[channel];
|
|
311
|
+
if (ref && !skipResume) {
|
|
312
|
+
args.push(`--resume=${ref}`);
|
|
313
|
+
} else if (!ref) {
|
|
314
|
+
args.push('--name', this._stableSessionName(channel));
|
|
315
|
+
}
|
|
316
|
+
return args;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
_buildSystemContext(channelName) {
|
|
320
|
+
return buildOpenclawSystemPrompt({
|
|
321
|
+
agentName: this.agentName,
|
|
322
|
+
workspaceId: this.workspaceId,
|
|
323
|
+
channelName,
|
|
324
|
+
endpoint: this.endpoint,
|
|
325
|
+
token: this.token,
|
|
326
|
+
mode: this._mode,
|
|
327
|
+
disabledModules: this.disabledModules,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ------------------------------------------------------------------
|
|
332
|
+
// Control actions (stop / interrupt)
|
|
333
|
+
// ------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
async _onControlAction(action, payload) {
|
|
336
|
+
if (action === 'stop') {
|
|
337
|
+
const channel = (payload && typeof payload === 'object') ? payload.channel : null;
|
|
338
|
+
if (channel && this._channelProcesses[channel]) {
|
|
339
|
+
this._stoppingChannels.add(channel);
|
|
340
|
+
await this._stopProcess(this._channelProcesses[channel]);
|
|
341
|
+
delete this._channelProcesses[channel];
|
|
342
|
+
delete this._channelQueues[channel];
|
|
343
|
+
try { await this.sendStatus(channel, 'Execution stopped by user'); } catch {}
|
|
344
|
+
} else {
|
|
345
|
+
for (const [ch, proc] of Object.entries(this._channelProcesses)) {
|
|
346
|
+
this._stoppingChannels.add(ch);
|
|
347
|
+
await this._stopProcess(proc);
|
|
348
|
+
delete this._channelProcesses[ch];
|
|
349
|
+
try { await this.sendStatus(ch, 'Execution stopped by user'); } catch {}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
await super._onControlAction(action, payload);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Daemon shutdown: tear down any in-flight Copilot subprocess so the thread
|
|
359
|
+
* doesn't get stuck showing "running".
|
|
360
|
+
*/
|
|
361
|
+
stop() {
|
|
362
|
+
for (const proc of Object.values(this._channelProcesses)) {
|
|
363
|
+
this._stopProcess(proc).catch(() => {});
|
|
364
|
+
}
|
|
365
|
+
super.stop();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Graceful → forceful interrupt of a Copilot subprocess and its whole process
|
|
370
|
+
* tree (Copilot may spawn shell/MCP children). SIGINT first so the CLI can
|
|
371
|
+
* cancel managed work, then SIGTERM/SIGKILL on the process group (Unix) or
|
|
372
|
+
* taskkill /T (Windows).
|
|
373
|
+
*/
|
|
374
|
+
async _stopProcess(proc) {
|
|
375
|
+
if (!proc || proc.exitCode !== null) return;
|
|
376
|
+
try {
|
|
377
|
+
if (IS_WINDOWS) {
|
|
378
|
+
try { proc.kill('SIGINT'); } catch {}
|
|
379
|
+
const exited = await this._waitExit(proc, 1500);
|
|
380
|
+
if (!exited) {
|
|
381
|
+
try { execSync(`taskkill /F /T /PID ${proc.pid}`, { timeout: 5000, windowsHide: true }); } catch {}
|
|
382
|
+
}
|
|
383
|
+
} else {
|
|
384
|
+
try { process.kill(-proc.pid, 'SIGINT'); } catch { try { proc.kill('SIGINT'); } catch {} }
|
|
385
|
+
let exited = await this._waitExit(proc, 1500);
|
|
386
|
+
if (!exited) {
|
|
387
|
+
try { process.kill(-proc.pid, 'SIGTERM'); } catch { try { proc.kill('SIGTERM'); } catch {} }
|
|
388
|
+
exited = await this._waitExit(proc, 2000);
|
|
389
|
+
}
|
|
390
|
+
if (!exited) {
|
|
391
|
+
try { process.kill(-proc.pid, 'SIGKILL'); } catch { try { proc.kill('SIGKILL'); } catch {} }
|
|
392
|
+
await this._waitExit(proc, 1000);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
} catch {}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
_waitExit(proc, ms) {
|
|
399
|
+
return new Promise((resolve) => {
|
|
400
|
+
if (proc.exitCode !== null) return resolve(true);
|
|
401
|
+
const t = setTimeout(() => resolve(false), ms);
|
|
402
|
+
proc.once('exit', () => { clearTimeout(t); resolve(true); });
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ------------------------------------------------------------------
|
|
407
|
+
// Message handler
|
|
408
|
+
// ------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
async _handleMessage(msg) {
|
|
411
|
+
const content = (msg.content || '').trim();
|
|
412
|
+
if (!content) return;
|
|
413
|
+
|
|
414
|
+
const channel = msg.sessionId || this.channelName;
|
|
415
|
+
this._stoppingChannels.delete(channel);
|
|
416
|
+
const sender = msg.senderName || msg.senderType || 'user';
|
|
417
|
+
this._log(`Processing message from ${sender} in ${channel}: ${redactSensitive(content).slice(0, 80)}...`);
|
|
418
|
+
|
|
419
|
+
if (!this._copilotBin) {
|
|
420
|
+
await this.sendError(channel,
|
|
421
|
+
'GitHub Copilot CLI not found. Install it with: npm install -g @github/copilot');
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Hard version gate BEFORE spawning a turn: an older CLI lacks the flags /
|
|
426
|
+
// JSONL output this adapter requires, so refuse rather than emit a confusing
|
|
427
|
+
// "unknown option" failure on every run.
|
|
428
|
+
const gate = this._checkVersionGate();
|
|
429
|
+
if (gate.compatible === false) {
|
|
430
|
+
await this.sendError(channel,
|
|
431
|
+
`GitHub Copilot CLI ${gate.version} is too old — this integration requires ${MIN_VERSION} or newer. Upgrade with: copilot update (or npm install -g @github/copilot).`);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Working directory must exist — never silently fall back to the repo cwd.
|
|
436
|
+
const wd = this.workingDir;
|
|
437
|
+
if (wd && !this._dirExists(wd)) {
|
|
438
|
+
await this.sendError(channel, `Working directory does not exist: ${wd}`);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
await this._autoTitleChannel(channel, content);
|
|
443
|
+
await this.sendStatus(channel, 'thinking...');
|
|
444
|
+
|
|
445
|
+
const fullPrompt = `${this._buildSystemContext(channel)}\n\n---\n\nUser message:\n${content}`;
|
|
446
|
+
|
|
447
|
+
// Up to 2 attempts: resume first, then a fresh session if resume was stale.
|
|
448
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
449
|
+
const skipResume = attempt > 0;
|
|
450
|
+
const args = this._buildArgs(fullPrompt, channel, { skipResume });
|
|
451
|
+
this._logSpawn(channel, args, skipResume);
|
|
452
|
+
|
|
453
|
+
let result;
|
|
454
|
+
try {
|
|
455
|
+
result = await this._runTurn(channel, args);
|
|
456
|
+
} catch (e) {
|
|
457
|
+
await this.sendError(channel, `Error: ${redactSensitive(e.message)}`);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (this._stoppingChannels.has(channel)) return; // user interrupted
|
|
462
|
+
|
|
463
|
+
// Stale resume → retry once without it.
|
|
464
|
+
if (result.staleSession && attempt === 0 && this._channelSessions[channel]) {
|
|
465
|
+
this._log(`Stale session for ${channel}; retrying with a fresh session`);
|
|
466
|
+
delete this._channelSessions[channel];
|
|
467
|
+
this._saveSessions();
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (result.errorMessage) {
|
|
472
|
+
await this.sendError(channel, result.errorMessage);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const text = (result.finalText || '').trim();
|
|
476
|
+
if (text) {
|
|
477
|
+
await this.sendResponse(channel, text);
|
|
478
|
+
} else if (result.timedOut) {
|
|
479
|
+
await this.sendError(channel, 'Copilot CLI timed out before producing a response.');
|
|
480
|
+
} else {
|
|
481
|
+
await this.sendResponse(channel, 'No response generated. Please try again.');
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
_dirExists(p) {
|
|
488
|
+
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Pre-launch version gate. Runs `copilot --version` once (cached), parses the
|
|
493
|
+
* version, and compares against MIN_VERSION. Returns
|
|
494
|
+
* { version, compatible: true|false|null }:
|
|
495
|
+
* • compatible:false → below MIN_VERSION, callers must not spawn.
|
|
496
|
+
* • compatible:null → version unparseable/undetectable ("unknown"); we do
|
|
497
|
+
* NOT block (let the run proceed; runtime errors will surface real issues).
|
|
498
|
+
* Mirrors the installer's generic gate semantics but lives in the adapter so a
|
|
499
|
+
* too-old CLI never gets a turn spawned against it.
|
|
500
|
+
*/
|
|
501
|
+
_checkVersionGate() {
|
|
502
|
+
if (this._versionGate) return this._versionGate;
|
|
503
|
+
let version = null;
|
|
504
|
+
try {
|
|
505
|
+
// Args as an array (no shell) so paths with spaces/backslashes are safe on
|
|
506
|
+
// every platform. _resolveExec already normalizes a Windows .cmd shim.
|
|
507
|
+
const [file, ...prefix] = this._resolveExec(this._copilotBin);
|
|
508
|
+
const raw = execFileSync(file, [...prefix, '--version'], {
|
|
509
|
+
encoding: 'utf-8', timeout: 8000, windowsHide: true, env: getEnhancedEnv(),
|
|
510
|
+
}).trim();
|
|
511
|
+
const m = raw.match(/(\d+\.\d+\.\d+)/);
|
|
512
|
+
version = m ? m[1] : null;
|
|
513
|
+
} catch {}
|
|
514
|
+
let compatible;
|
|
515
|
+
if (!version) compatible = null;
|
|
516
|
+
else compatible = compareVersions(version, MIN_VERSION) >= 0;
|
|
517
|
+
this._versionGate = { version, compatible };
|
|
518
|
+
return this._versionGate;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
_logSpawn(channel, args, skipResume) {
|
|
522
|
+
// Log a redacted, prompt-free shape of the command. The prompt is replaced
|
|
523
|
+
// with a placeholder; tokens never appear in argv but redact defensively.
|
|
524
|
+
const shown = [];
|
|
525
|
+
for (let i = 0; i < args.length; i++) {
|
|
526
|
+
if (args[i] === '-p') { shown.push('-p', '<prompt>'); i++; continue; }
|
|
527
|
+
shown.push(args[i]);
|
|
528
|
+
}
|
|
529
|
+
this._log(`Spawning copilot ${redactSensitive(shown.join(' '))}${skipResume ? ' (no-resume retry)' : ''}`);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Run one Copilot turn: spawn the subprocess, stream-parse stdout, map events
|
|
534
|
+
* onto workspace messages, capture the session id, and resolve a summary of
|
|
535
|
+
* the turn. Never rejects on CLI failure — failures come back as
|
|
536
|
+
* `errorMessage` / `staleSession` / `timedOut` so the caller can react.
|
|
537
|
+
*/
|
|
538
|
+
_runTurn(channel, args) {
|
|
539
|
+
const [file, ...prefix] = this._resolveExec(this._copilotBin);
|
|
540
|
+
const env = getEnhancedEnv({ ...(this.agentEnv || process.env) });
|
|
541
|
+
const cwd = this.workingDir || defaultAgentWorkdir(this.agentName);
|
|
542
|
+
|
|
543
|
+
return new Promise((resolve) => {
|
|
544
|
+
let proc;
|
|
545
|
+
try {
|
|
546
|
+
proc = spawn(file, [...prefix, ...args], {
|
|
547
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
548
|
+
env,
|
|
549
|
+
cwd,
|
|
550
|
+
detached: !IS_WINDOWS,
|
|
551
|
+
windowsHide: true,
|
|
552
|
+
});
|
|
553
|
+
} catch (e) {
|
|
554
|
+
return resolve({ errorMessage: `Failed to launch Copilot CLI: ${redactSensitive(e.message)}` });
|
|
555
|
+
}
|
|
556
|
+
this._channelProcesses[channel] = proc;
|
|
557
|
+
|
|
558
|
+
// Guard the child's stdio streams against 'error'. When the process is
|
|
559
|
+
// SIGKILL'd on stop, macOS Node can emit a benign EPIPE/ECONNRESET 'error'
|
|
560
|
+
// on stdout/stderr a tick later; with a 'data' listener but no 'error'
|
|
561
|
+
// listener that becomes an uncaughtException which crashes the
|
|
562
|
+
// (single-process, e.g. Node 18) test run with exit 1 and no `not ok`.
|
|
563
|
+
// No-op — data handling below is unchanged.
|
|
564
|
+
if (proc.stdout) proc.stdout.on('error', () => {});
|
|
565
|
+
if (proc.stderr) proc.stderr.on('error', () => {});
|
|
566
|
+
|
|
567
|
+
const parser = new CopilotStreamParser();
|
|
568
|
+
// Final-answer accumulation. `finalParts` holds authoritative `text`
|
|
569
|
+
// events; `deltaBuf` holds streamed `text_delta` text for the current
|
|
570
|
+
// contiguous block. A final `text` event SUPERSEDES the deltas of its
|
|
571
|
+
// block (deltas are the streamed pieces of the same message) so the answer
|
|
572
|
+
// is never duplicated. A tool/file/shell event between text starts a new
|
|
573
|
+
// block (only the last contiguous block becomes the answer; earlier text
|
|
574
|
+
// was shown live as thinking).
|
|
575
|
+
const finalParts = [];
|
|
576
|
+
let deltaBuf = '';
|
|
577
|
+
const accumulatedText = () => (finalParts.length ? finalParts.join('\n') : deltaBuf).trim();
|
|
578
|
+
let sawToolSinceText = false;
|
|
579
|
+
let errorEvent = null;
|
|
580
|
+
let stderrBuf = '';
|
|
581
|
+
let pending = Promise.resolve();
|
|
582
|
+
let settled = false;
|
|
583
|
+
|
|
584
|
+
const timeout = setTimeout(() => {
|
|
585
|
+
this._stopProcess(proc).catch(() => {});
|
|
586
|
+
finish({ timedOut: true, finalText: accumulatedText() });
|
|
587
|
+
}, TURN_TIMEOUT_MS);
|
|
588
|
+
|
|
589
|
+
const finish = (summary) => {
|
|
590
|
+
if (settled) return;
|
|
591
|
+
settled = true;
|
|
592
|
+
clearTimeout(timeout);
|
|
593
|
+
delete this._channelProcesses[channel];
|
|
594
|
+
resolve(summary);
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const handle = async (ev) => {
|
|
598
|
+
switch (ev.kind) {
|
|
599
|
+
case 'session':
|
|
600
|
+
if (ev.sessionId) {
|
|
601
|
+
this._channelSessions[channel] = ev.sessionId;
|
|
602
|
+
this._saveSessions();
|
|
603
|
+
}
|
|
604
|
+
break;
|
|
605
|
+
case 'text_delta':
|
|
606
|
+
// Streamed interim text is shown live as `thinking`; the final
|
|
607
|
+
// `text` event (if any) is the authoritative answer.
|
|
608
|
+
if (sawToolSinceText) { finalParts.length = 0; deltaBuf = ''; sawToolSinceText = false; }
|
|
609
|
+
if (ev.text) { deltaBuf += ev.text; try { await this.sendThinking(channel, ev.text); } catch {} }
|
|
610
|
+
break;
|
|
611
|
+
case 'text':
|
|
612
|
+
// A complete message supersedes the deltas streamed for this block.
|
|
613
|
+
if (sawToolSinceText) { finalParts.length = 0; sawToolSinceText = false; }
|
|
614
|
+
deltaBuf = '';
|
|
615
|
+
if (ev.text) finalParts.push(ev.text);
|
|
616
|
+
break;
|
|
617
|
+
case 'reasoning':
|
|
618
|
+
if (ev.text) { try { await this.sendThinking(channel, ev.text); } catch {} }
|
|
619
|
+
break;
|
|
620
|
+
case 'tool_start':
|
|
621
|
+
sawToolSinceText = true;
|
|
622
|
+
try { await this.sendStatus(channel, this._describeTool(ev)); } catch {}
|
|
623
|
+
break;
|
|
624
|
+
case 'shell':
|
|
625
|
+
sawToolSinceText = true;
|
|
626
|
+
try {
|
|
627
|
+
const ec = (ev.exitCode != null) ? ` (exit ${ev.exitCode})` : '';
|
|
628
|
+
await this.sendStatus(channel, `**Running:** \`${redactSensitive(String(ev.command)).slice(0, 200)}\`${ec}`);
|
|
629
|
+
} catch {}
|
|
630
|
+
break;
|
|
631
|
+
case 'file_change':
|
|
632
|
+
sawToolSinceText = true;
|
|
633
|
+
try { await this.sendStatus(channel, `**${ev.action === 'read' ? 'Reading' : 'Editing'}:** \`${ev.path}\``); } catch {}
|
|
634
|
+
break;
|
|
635
|
+
case 'tool_result':
|
|
636
|
+
// Tool output is operational detail, not an assistant reply — keep it
|
|
637
|
+
// out of the chat body; just log a redacted note.
|
|
638
|
+
this._log(`tool_result ${ev.tool}${ev.isError ? ' (error)' : ''}`);
|
|
639
|
+
break;
|
|
640
|
+
case 'permission':
|
|
641
|
+
try { await this.sendStatus(channel, `Permission ${ev.granted ? 'granted' : 'denied'}: ${redactSensitive(ev.detail).slice(0, 160)}`); } catch {}
|
|
642
|
+
break;
|
|
643
|
+
case 'ask_user':
|
|
644
|
+
// We launched with --no-ask-user; if a prompt still surfaces, the
|
|
645
|
+
// workspace can't answer it. Record it so the turn fails clearly.
|
|
646
|
+
errorEvent = errorEvent || { message: 'Copilot requested interactive input, which the workspace cannot provide. Re-run with a more specific task.' };
|
|
647
|
+
break;
|
|
648
|
+
case 'usage':
|
|
649
|
+
this._log(`usage model=${ev.model || '?'}`);
|
|
650
|
+
break;
|
|
651
|
+
case 'error':
|
|
652
|
+
errorEvent = errorEvent || { message: ev.message, code: ev.code };
|
|
653
|
+
break;
|
|
654
|
+
case 'done':
|
|
655
|
+
break;
|
|
656
|
+
case 'unknown':
|
|
657
|
+
// Preserve a redacted diagnostic; never crash the task.
|
|
658
|
+
this._log(`unknown event: ${ev.raw}`);
|
|
659
|
+
break;
|
|
660
|
+
default:
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
proc.stdout.on('data', (chunk) => {
|
|
666
|
+
for (const ev of parser.push(chunk)) {
|
|
667
|
+
pending = pending.then(() => handle(ev)).catch(() => {});
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
if (proc.stderr) {
|
|
671
|
+
proc.stderr.on('data', (c) => { stderrBuf += c.toString('utf-8'); });
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
proc.on('error', (err) => {
|
|
675
|
+
finish({ errorMessage: `Copilot CLI failed to start: ${redactSensitive(err.message)}` });
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
proc.on('exit', async (code) => {
|
|
679
|
+
try { for (const ev of parser.flush()) await handle(ev); } catch {}
|
|
680
|
+
try { await pending; } catch {}
|
|
681
|
+
|
|
682
|
+
if (this._stoppingChannels.has(channel)) {
|
|
683
|
+
return finish({ interrupted: true, finalText: accumulatedText() });
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const finalText = accumulatedText();
|
|
687
|
+
const classified = this._classifyOutcome(code, errorEvent, stderrBuf);
|
|
688
|
+
if (classified) {
|
|
689
|
+
// A resume against a vanished session: surface as stale so the caller
|
|
690
|
+
// retries fresh instead of erroring the user.
|
|
691
|
+
if (classified.staleSession) return finish({ staleSession: true });
|
|
692
|
+
// If the CLI still produced a usable answer, prefer it over a soft error.
|
|
693
|
+
if (finalText && !classified.hard) return finish({ finalText });
|
|
694
|
+
return finish({ errorMessage: classified.message });
|
|
695
|
+
}
|
|
696
|
+
finish({ finalText });
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
_describeTool(ev) {
|
|
702
|
+
let preview = '';
|
|
703
|
+
const inp = ev.input;
|
|
704
|
+
if (inp && typeof inp === 'object') {
|
|
705
|
+
preview = inp.command || inp.path || inp.file || inp.file_path || inp.pattern
|
|
706
|
+
|| inp.query || inp.url || (typeof inp.content === 'string' ? inp.content.slice(0, 80) : '')
|
|
707
|
+
|| JSON.stringify(inp).slice(0, 120);
|
|
708
|
+
} else if (inp != null) {
|
|
709
|
+
preview = String(inp).slice(0, 120);
|
|
710
|
+
}
|
|
711
|
+
return `${ev.tool} › ${redactSensitive(String(preview))}`;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ------------------------------------------------------------------
|
|
715
|
+
// Error classification (based on event, exit code, and stderr)
|
|
716
|
+
// ------------------------------------------------------------------
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Turn a turn's terminal signals into a concise, actionable user message and
|
|
720
|
+
* a stale-session hint. Returns null when the turn succeeded. Detailed,
|
|
721
|
+
* redacted text only goes to the dev log.
|
|
722
|
+
*
|
|
723
|
+
* String patterns are anchored on REAL Copilot CLI v1.0.63 stderr (see
|
|
724
|
+
* test/fixtures/copilot-cli-real-samples.md) — note auth/session/network
|
|
725
|
+
* failures in non-interactive mode print to stderr with EMPTY stdout (no JSONL
|
|
726
|
+
* error event), so classification is driven by stderr + exit code. Broader
|
|
727
|
+
* synonyms are kept as forward-compatible fallbacks.
|
|
728
|
+
*/
|
|
729
|
+
_classifyOutcome(exitCode, errorEvent, stderr) {
|
|
730
|
+
const blob = redactSensitive(`${errorEvent ? (errorEvent.message || '') + ' ' + (errorEvent.code || '') : ''} ${stderr || ''}`).trim();
|
|
731
|
+
const ok = (exitCode === 0 || exitCode == null) && !errorEvent;
|
|
732
|
+
if (ok) return null;
|
|
733
|
+
|
|
734
|
+
if (blob) this._log(`Copilot failure (exit=${exitCode}): ${blob.slice(0, 400)}`);
|
|
735
|
+
|
|
736
|
+
const has = (re) => re.test(blob);
|
|
737
|
+
|
|
738
|
+
// Session resume miss → retry fresh (not a user-facing error).
|
|
739
|
+
// Real: "Error: No session, task, or name matched 'NAME'."
|
|
740
|
+
if (has(/no session,? .*matched|session\s+(not\s+found|expired|unknown|invalid)|no such session/i)) {
|
|
741
|
+
return { staleSession: true };
|
|
742
|
+
}
|
|
743
|
+
// Token present but REJECTED — must be checked BEFORE the no-credentials rule
|
|
744
|
+
// because the real CLI appends the SAME "To authenticate … gh auth login"
|
|
745
|
+
// help block to both messages; only the leading line distinguishes them.
|
|
746
|
+
// Real: "Authentication token found but could not be validated. Failed to
|
|
747
|
+
// fetch PAT user login (401): ... Bad credentials".
|
|
748
|
+
if (has(/could not be validated|bad credentials|authentication token found but|token.*(expired|revoked|invalid)|invalid.*token|\b401\b|unauthorized/i)) {
|
|
749
|
+
return { hard: true, message: 'GitHub Copilot token is invalid, expired, or revoked (401). Re-authenticate with a fine-grained token that has the "Copilot Requests" permission, or run `copilot` and use /login. (Classic ghp_ tokens are not supported.)' };
|
|
750
|
+
}
|
|
751
|
+
// No credentials at all. Real distinguishing line: "No authentication
|
|
752
|
+
// information found." (Do NOT key on the shared "gh auth login" tail.)
|
|
753
|
+
if (has(/no authentication information|not (logged|signed) in|no (credentials|token)\b|login required/i)) {
|
|
754
|
+
return { hard: true, message: 'Not signed in to GitHub Copilot. Set COPILOT_GITHUB_TOKEN (or GH_TOKEN/GITHUB_TOKEN), run `gh auth login`, or run `copilot` and use /login.' };
|
|
755
|
+
}
|
|
756
|
+
if (has(/\b403\b|forbidden|sso|saml|single sign-on|not authorized for/i)) {
|
|
757
|
+
return { hard: true, message: 'Access denied (403). This is usually SAML/SSO not authorized for your token, or insufficient token scope for Copilot CLI.' };
|
|
758
|
+
}
|
|
759
|
+
if (has(/organization|enterprise|policy|disabled by|not allowed by|blocked by/i)) {
|
|
760
|
+
return { hard: true, message: 'GitHub Copilot CLI is blocked by an organization/enterprise policy for this account.' };
|
|
761
|
+
}
|
|
762
|
+
if (has(/subscription|not entitled|no copilot|copilot.*(unavailable|inactive)|no seat|seat/i)) {
|
|
763
|
+
return { hard: true, message: 'No active GitHub Copilot subscription/seat for this account.' };
|
|
764
|
+
}
|
|
765
|
+
// GitHub Enterprise host misconfiguration (GH_HOST / COPILOT_GH_HOST / --host).
|
|
766
|
+
if (has(/gh_host|copilot_gh_host|enterprise.*host|host.*not.*found|unknown host|could not resolve host|invalid host/i)) {
|
|
767
|
+
return { hard: true, message: 'GitHub host configuration error. Check GH_HOST / COPILOT_GH_HOST (GitHub Enterprise data-residency hostname).' };
|
|
768
|
+
}
|
|
769
|
+
// BYOK custom provider misconfiguration (COPILOT_PROVIDER_*).
|
|
770
|
+
if (has(/copilot_provider|provider.*(base.?url|api.?key|not configured|invalid)|byok/i)) {
|
|
771
|
+
return { hard: true, message: 'Custom model provider (BYOK) is misconfigured. Check COPILOT_PROVIDER_BASE_URL / COPILOT_PROVIDER_API_KEY / COPILOT_PROVIDER_TYPE.' };
|
|
772
|
+
}
|
|
773
|
+
if (has(/model.*(not found|unavailable|unsupported)|unknown model|invalid model/i)) {
|
|
774
|
+
return { hard: true, message: 'The requested model is unavailable for this account. Clear COPILOT_MODEL or pick a supported model.' };
|
|
775
|
+
}
|
|
776
|
+
if (has(/rate limit|429|too many requests|quota|credit/i)) {
|
|
777
|
+
return { hard: true, message: 'Rate limited or out of Copilot credits. Please wait and try again.' };
|
|
778
|
+
}
|
|
779
|
+
if (has(/check your network|network|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|getaddrinfo|socket hang up|503|502|service unavailable/i)) {
|
|
780
|
+
return { hard: true, message: 'Network or GitHub service error reaching Copilot. Check connectivity and try again.' };
|
|
781
|
+
}
|
|
782
|
+
if (has(/permission|not authorized|denied|not allowed to (write|read|run)/i)) {
|
|
783
|
+
return { hard: true, message: 'Copilot was denied a required permission (path/tool). The task may need access outside the working directory.' };
|
|
784
|
+
}
|
|
785
|
+
if (errorEvent && errorEvent.message) {
|
|
786
|
+
return { hard: true, message: `Copilot error: ${redactSensitive(errorEvent.message).slice(0, 300)}` };
|
|
787
|
+
}
|
|
788
|
+
if (exitCode && exitCode !== 0) {
|
|
789
|
+
return { hard: false, message: `Copilot CLI exited with code ${exitCode}.` };
|
|
790
|
+
}
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
module.exports = CopilotAdapter;
|
|
796
|
+
module.exports.MIN_VERSION = MIN_VERSION;
|