@minniexcode/codex-switch 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/add-provider.js +10 -16
- package/dist/app/bridge.js +8 -13
- package/dist/app/get-status.js +15 -12
- package/dist/app/run-doctor.js +17 -18
- package/dist/app/switch-provider.js +6 -11
- package/dist/commands/handlers.js +32 -69
- package/dist/domain/providers.js +9 -9
- package/dist/runtime/copilot-adapter.js +6 -1
- package/dist/runtime/copilot-bridge.js +109 -76
- package/dist/runtime/copilot-http-bridge-worker.js +228 -0
- package/dist/runtime/copilot-token.js +294 -0
- package/docs/Design/codex-switch-v0.2.0-design.md +56 -0
- package/package.json +1 -1
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.setCopilotTokenExchangeImplementation = setCopilotTokenExchangeImplementation;
|
|
37
|
+
exports.resetCopilotTokenExchangeImplementation = resetCopilotTokenExchangeImplementation;
|
|
38
|
+
exports.getCopilotRequestHeaders = getCopilotRequestHeaders;
|
|
39
|
+
exports.getGithubTokenPath = getGithubTokenPath;
|
|
40
|
+
exports.readGithubToken = readGithubToken;
|
|
41
|
+
exports.writeGithubToken = writeGithubToken;
|
|
42
|
+
exports.startDeviceFlow = startDeviceFlow;
|
|
43
|
+
exports.pollDeviceFlowToken = pollDeviceFlowToken;
|
|
44
|
+
exports.exchangeForCopilotToken = exchangeForCopilotToken;
|
|
45
|
+
exports.createTokenManager = createTokenManager;
|
|
46
|
+
exports.createStaticTokenManager = createStaticTokenManager;
|
|
47
|
+
const https = __importStar(require("node:https"));
|
|
48
|
+
const fs = __importStar(require("node:fs"));
|
|
49
|
+
const path = __importStar(require("node:path"));
|
|
50
|
+
const crypto = __importStar(require("node:crypto"));
|
|
51
|
+
const codex_paths_1 = require("../storage/codex-paths");
|
|
52
|
+
const errors_1 = require("../domain/errors");
|
|
53
|
+
const GITHUB_OAUTH_CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
|
54
|
+
const GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code";
|
|
55
|
+
const GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
56
|
+
const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
|
|
57
|
+
const EDITOR_VERSION = "vscode/1.100.0";
|
|
58
|
+
const COPILOT_CHAT_VERSION = "copilot-chat/0.30.0";
|
|
59
|
+
const USER_AGENT = "GitHubCopilotChat/0.30.0";
|
|
60
|
+
let exchangeImplementation = null;
|
|
61
|
+
function setCopilotTokenExchangeImplementation(impl) {
|
|
62
|
+
exchangeImplementation = impl;
|
|
63
|
+
}
|
|
64
|
+
function resetCopilotTokenExchangeImplementation() {
|
|
65
|
+
exchangeImplementation = null;
|
|
66
|
+
}
|
|
67
|
+
const SESSION_ID = crypto.randomUUID();
|
|
68
|
+
const MACHINE_ID = crypto.randomBytes(32).toString("hex");
|
|
69
|
+
function getCopilotRequestHeaders(copilotToken, requestId) {
|
|
70
|
+
return {
|
|
71
|
+
"authorization": `Bearer ${copilotToken}`,
|
|
72
|
+
"content-type": "application/json",
|
|
73
|
+
"copilot-integration-id": "vscode-chat",
|
|
74
|
+
"editor-version": EDITOR_VERSION,
|
|
75
|
+
"editor-plugin-version": COPILOT_CHAT_VERSION,
|
|
76
|
+
"user-agent": USER_AGENT,
|
|
77
|
+
"openai-intent": "conversation-panel",
|
|
78
|
+
"x-interaction-type": "conversation-panel",
|
|
79
|
+
"x-github-api-version": "2026-01-09",
|
|
80
|
+
"x-request-id": requestId ?? crypto.randomUUID(),
|
|
81
|
+
"vscode-sessionid": SESSION_ID,
|
|
82
|
+
"vscode-machineid": MACHINE_ID,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function getGithubTokenPath(toolHomeDir) {
|
|
86
|
+
const home = (0, codex_paths_1.resolveCodexSwitchHome)(toolHomeDir);
|
|
87
|
+
return path.join(home, "github-token");
|
|
88
|
+
}
|
|
89
|
+
function readGithubToken(toolHomeDir) {
|
|
90
|
+
const tokenPath = getGithubTokenPath(toolHomeDir);
|
|
91
|
+
if (!fs.existsSync(tokenPath)) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return fs.readFileSync(tokenPath, "utf8").trim();
|
|
95
|
+
}
|
|
96
|
+
function writeGithubToken(token, toolHomeDir) {
|
|
97
|
+
const tokenPath = getGithubTokenPath(toolHomeDir);
|
|
98
|
+
fs.mkdirSync(path.dirname(tokenPath), { recursive: true });
|
|
99
|
+
fs.writeFileSync(tokenPath, token, "utf8");
|
|
100
|
+
}
|
|
101
|
+
async function startDeviceFlow() {
|
|
102
|
+
const body = `client_id=${GITHUB_OAUTH_CLIENT_ID}&scope=read:user`;
|
|
103
|
+
const response = await httpsPost(GITHUB_DEVICE_CODE_URL, body, {
|
|
104
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
105
|
+
"accept": "application/json",
|
|
106
|
+
});
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
throw (0, errors_1.cliError)("GITHUB_DEVICE_FLOW_FAILED", `GitHub device flow initiation failed: ${response.status}`, {
|
|
109
|
+
status: response.status,
|
|
110
|
+
body: response.body,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const data = JSON.parse(response.body);
|
|
114
|
+
return {
|
|
115
|
+
userCode: String(data.user_code ?? ""),
|
|
116
|
+
verificationUri: String(data.verification_uri ?? "https://github.com/login/device"),
|
|
117
|
+
deviceCode: String(data.device_code ?? ""),
|
|
118
|
+
interval: Number(data.interval ?? 5),
|
|
119
|
+
expiresIn: Number(data.expires_in ?? 900),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
async function pollDeviceFlowToken(deviceCode, interval, expiresIn) {
|
|
123
|
+
const deadline = Date.now() + expiresIn * 1000;
|
|
124
|
+
let pollInterval = interval;
|
|
125
|
+
while (Date.now() < deadline) {
|
|
126
|
+
await sleep(pollInterval * 1000);
|
|
127
|
+
const body = `client_id=${GITHUB_OAUTH_CLIENT_ID}&device_code=${deviceCode}&grant_type=urn:ietf:params:oauth:grant-type:device_code`;
|
|
128
|
+
const response = await httpsPost(GITHUB_ACCESS_TOKEN_URL, body, {
|
|
129
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
130
|
+
"accept": "application/json",
|
|
131
|
+
});
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const data = JSON.parse(response.body);
|
|
136
|
+
if (data.access_token && typeof data.access_token === "string") {
|
|
137
|
+
return data.access_token;
|
|
138
|
+
}
|
|
139
|
+
const error = String(data.error ?? "");
|
|
140
|
+
if (error === "authorization_pending") {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (error === "slow_down") {
|
|
144
|
+
pollInterval += 5;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (error === "expired_token" || error === "access_denied") {
|
|
148
|
+
throw (0, errors_1.cliError)("GITHUB_DEVICE_FLOW_FAILED", `GitHub device flow failed: ${error}`, { error });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
throw (0, errors_1.cliError)("GITHUB_DEVICE_FLOW_FAILED", "GitHub device flow timed out waiting for user authorization.", {});
|
|
152
|
+
}
|
|
153
|
+
async function exchangeForCopilotToken(githubPat) {
|
|
154
|
+
if (exchangeImplementation) {
|
|
155
|
+
return exchangeImplementation(githubPat);
|
|
156
|
+
}
|
|
157
|
+
const response = await httpsGet(COPILOT_TOKEN_URL, {
|
|
158
|
+
"authorization": `token ${githubPat}`,
|
|
159
|
+
"content-type": "application/json",
|
|
160
|
+
"accept": "application/json",
|
|
161
|
+
"editor-version": EDITOR_VERSION,
|
|
162
|
+
"editor-plugin-version": COPILOT_CHAT_VERSION,
|
|
163
|
+
"user-agent": USER_AGENT,
|
|
164
|
+
"x-github-api-version": "2026-01-09",
|
|
165
|
+
});
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
if (response.status === 401) {
|
|
168
|
+
throw (0, errors_1.cliError)("COPILOT_AUTH_REQUIRED", "GitHub token is invalid or expired. Run `codexs login copilot` to re-authenticate.", {
|
|
169
|
+
status: response.status,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
throw (0, errors_1.cliError)("COPILOT_TOKEN_EXCHANGE_FAILED", `Failed to exchange GitHub token for Copilot token: HTTP ${response.status}`, {
|
|
173
|
+
status: response.status,
|
|
174
|
+
body: response.body,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
const data = JSON.parse(response.body);
|
|
178
|
+
const token = String(data.token ?? "");
|
|
179
|
+
if (!token) {
|
|
180
|
+
throw (0, errors_1.cliError)("COPILOT_TOKEN_EXCHANGE_FAILED", "Copilot token exchange returned empty token.", { data });
|
|
181
|
+
}
|
|
182
|
+
const endpoints = data.endpoints;
|
|
183
|
+
const apiBaseUrl = String(endpoints?.api ?? "https://api.githubcopilot.com");
|
|
184
|
+
return {
|
|
185
|
+
token,
|
|
186
|
+
expiresAt: Number(data.expires_at ?? Date.now() / 1000 + 1800),
|
|
187
|
+
apiBaseUrl: apiBaseUrl.replace(/\/$/, ""),
|
|
188
|
+
refreshIn: Number(data.refresh_in ?? 1500),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function createTokenManager(githubPat) {
|
|
192
|
+
let currentToken = null;
|
|
193
|
+
let refreshTimer = null;
|
|
194
|
+
let refreshing = null;
|
|
195
|
+
async function refresh() {
|
|
196
|
+
currentToken = await exchangeForCopilotToken(githubPat);
|
|
197
|
+
scheduleRefresh();
|
|
198
|
+
}
|
|
199
|
+
function scheduleRefresh() {
|
|
200
|
+
if (refreshTimer) {
|
|
201
|
+
clearTimeout(refreshTimer);
|
|
202
|
+
}
|
|
203
|
+
const delayMs = Math.max((currentToken.refreshIn - 60) * 1000, 30000);
|
|
204
|
+
refreshTimer = setTimeout(() => {
|
|
205
|
+
refreshing = refresh().catch(() => {
|
|
206
|
+
// retry after 30s on failure
|
|
207
|
+
refreshTimer = setTimeout(() => { refreshing = refresh(); }, 30000);
|
|
208
|
+
});
|
|
209
|
+
}, delayMs);
|
|
210
|
+
refreshTimer.unref();
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
async getToken() {
|
|
214
|
+
if (!currentToken || Date.now() / 1000 >= currentToken.expiresAt - 60) {
|
|
215
|
+
if (!refreshing) {
|
|
216
|
+
refreshing = refresh();
|
|
217
|
+
}
|
|
218
|
+
await refreshing;
|
|
219
|
+
refreshing = null;
|
|
220
|
+
}
|
|
221
|
+
return currentToken.token;
|
|
222
|
+
},
|
|
223
|
+
getApiBaseUrl() {
|
|
224
|
+
return currentToken?.apiBaseUrl ?? "https://api.githubcopilot.com";
|
|
225
|
+
},
|
|
226
|
+
invalidate() {
|
|
227
|
+
currentToken = null;
|
|
228
|
+
},
|
|
229
|
+
stop() {
|
|
230
|
+
if (refreshTimer) {
|
|
231
|
+
clearTimeout(refreshTimer);
|
|
232
|
+
refreshTimer = null;
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function createStaticTokenManager(token) {
|
|
238
|
+
return {
|
|
239
|
+
async getToken() {
|
|
240
|
+
return token;
|
|
241
|
+
},
|
|
242
|
+
getApiBaseUrl() {
|
|
243
|
+
return "https://api.githubcopilot.com";
|
|
244
|
+
},
|
|
245
|
+
invalidate() { },
|
|
246
|
+
stop() { },
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function httpsPost(url, body, headers) {
|
|
250
|
+
return new Promise((resolve, reject) => {
|
|
251
|
+
const parsed = new URL(url);
|
|
252
|
+
const req = https.request({
|
|
253
|
+
hostname: parsed.hostname,
|
|
254
|
+
port: parsed.port || 443,
|
|
255
|
+
path: parsed.pathname + parsed.search,
|
|
256
|
+
method: "POST",
|
|
257
|
+
headers: { ...headers, "content-length": Buffer.byteLength(body).toString() },
|
|
258
|
+
}, (res) => {
|
|
259
|
+
const chunks = [];
|
|
260
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
261
|
+
res.on("end", () => {
|
|
262
|
+
const responseBody = Buffer.concat(chunks).toString("utf8");
|
|
263
|
+
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, body: responseBody });
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
req.on("error", reject);
|
|
267
|
+
req.write(body);
|
|
268
|
+
req.end();
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
function httpsGet(url, headers) {
|
|
272
|
+
return new Promise((resolve, reject) => {
|
|
273
|
+
const parsed = new URL(url);
|
|
274
|
+
const req = https.request({
|
|
275
|
+
hostname: parsed.hostname,
|
|
276
|
+
port: parsed.port || 443,
|
|
277
|
+
path: parsed.pathname + parsed.search,
|
|
278
|
+
method: "GET",
|
|
279
|
+
headers,
|
|
280
|
+
}, (res) => {
|
|
281
|
+
const chunks = [];
|
|
282
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
283
|
+
res.on("end", () => {
|
|
284
|
+
const responseBody = Buffer.concat(chunks).toString("utf8");
|
|
285
|
+
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, body: responseBody });
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
req.on("error", reject);
|
|
289
|
+
req.end();
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
function sleep(ms) {
|
|
293
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
294
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# codex-switch v0.2.0 Design
|
|
2
|
+
|
|
3
|
+
`0.2.0` is a major architecture release that replaces the Copilot SDK-based authentication and runtime model with a direct GitHub device-flow token exchange and HTTP proxy bridge.
|
|
4
|
+
|
|
5
|
+
## Architecture Changes
|
|
6
|
+
|
|
7
|
+
### Authentication: SDK-based to Device-Flow Token
|
|
8
|
+
|
|
9
|
+
- The Copilot SDK (`@github/copilot-sdk`) is no longer required for authentication or session management.
|
|
10
|
+
- Authentication is now handled via GitHub OAuth Device Flow (`login copilot`), which produces a GitHub personal access token stored at `<toolHomeDir>/github-token`.
|
|
11
|
+
- The GitHub PAT is exchanged for a short-lived Copilot API token via `POST /copilot_internal/v2/token` before every bridge start or switch operation.
|
|
12
|
+
- `TokenManager` handles background token refresh and expiry-aware caching with a new `invalidate()` method for forced refresh on upstream 401 responses.
|
|
13
|
+
|
|
14
|
+
### Bridge: SDK Session to HTTP Proxy
|
|
15
|
+
|
|
16
|
+
- The bridge worker (`copilot-http-bridge-worker.ts`) is now a pure HTTP reverse proxy between the local OpenAI-compatible surface and `api.githubcopilot.com`.
|
|
17
|
+
- Copilot token lifecycle (exchange, refresh, invalidation) is managed inside the worker via `createTokenManager`.
|
|
18
|
+
- On upstream 401, the worker invalidates its cached token and retries once with a freshly exchanged token.
|
|
19
|
+
- A `createStaticTokenManager` variant supports test scenarios where the exchange should be bypassed (`CODEX_SWITCH_BRIDGE_COPILOT_TOKEN` env var).
|
|
20
|
+
|
|
21
|
+
### Path Resolution: toolHomeDir Propagation
|
|
22
|
+
|
|
23
|
+
- `toolHomeDir` is now explicitly threaded through all functions that read the GitHub token: `switchProvider`, `startBridge`, `getStatus`, `runDoctor`, and the bridge worker spawn.
|
|
24
|
+
- The bridge worker receives the correct `toolHomeDir` (not `runtimeDir`) via `CODEX_SWITCH_TOOL_HOME_DIR`, ensuring the token is found at `<toolHomeDir>/github-token` rather than a nested runtime subdirectory.
|
|
25
|
+
- `readGithubToken(toolHomeDir?)` resolves the home directory in a consistent order: explicit arg, `CODEXS_HOME` env var, then `~/.config/codex-switch`.
|
|
26
|
+
|
|
27
|
+
### Provider Runtime Kind
|
|
28
|
+
|
|
29
|
+
- New providers created via `add --copilot` now use `kind: "copilot-http-proxy"` instead of `kind: "copilot-sdk-bridge"`.
|
|
30
|
+
- Both kinds are accepted by `isCopilotBridgeProvider` for backward compatibility with existing provider files.
|
|
31
|
+
- The distinction is cosmetic; both route through the same HTTP bridge worker.
|
|
32
|
+
|
|
33
|
+
### Status Contract
|
|
34
|
+
|
|
35
|
+
- `getStatus` output `copilotSdk` field is simplified to `{ installed: boolean, source: string }` reflecting the presence of a GitHub token rather than an SDK install.
|
|
36
|
+
- `copilotAuth` reflects the token exchange readiness rather than SDK session health.
|
|
37
|
+
- Legacy fields (`installDir`, `packageName`, `packageVersion`) are removed from the status contract.
|
|
38
|
+
|
|
39
|
+
## Test Infrastructure
|
|
40
|
+
|
|
41
|
+
- `setCopilotTokenExchangeImplementation` / `resetCopilotTokenExchangeImplementation` provide an in-process mock for the Copilot token exchange, enabling offline test execution.
|
|
42
|
+
- The bridge worker supports `CODEX_SWITCH_BRIDGE_COPILOT_TOKEN` env var to skip the real exchange in spawned child processes during tests.
|
|
43
|
+
- Integration tests that previously relied on fake SDK mock responses now verify state and configuration correctness without making HTTP requests through the bridge (bridge request handling is covered by `copilot-bridge-contract.spec.js`).
|
|
44
|
+
|
|
45
|
+
## Breaking Changes
|
|
46
|
+
|
|
47
|
+
- `copilotSdk` status output no longer includes `installDir`, `packageName`, or `packageVersion`.
|
|
48
|
+
- Provider `runtimeKind` for newly-created Copilot providers is `"copilot-http-proxy"` instead of `"copilot-sdk-bridge"`.
|
|
49
|
+
- `switchProvider` requires `toolHomeDir` to be passed explicitly when operating outside the default env-var-resolved home.
|
|
50
|
+
- The Copilot SDK (`@github/copilot-sdk`) is no longer a runtime dependency for authentication flows.
|
|
51
|
+
|
|
52
|
+
## Non-Goals
|
|
53
|
+
|
|
54
|
+
- The Copilot SDK is not removed from the repository; existing workflows that depend on it for non-auth purposes remain unchanged.
|
|
55
|
+
- No migration of existing `copilot-sdk-bridge` provider records to `copilot-http-proxy`; both are accepted.
|
|
56
|
+
- No changes to direct (non-Copilot) provider workflows.
|