@minniexcode/codex-switch 0.1.4 → 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.
@@ -0,0 +1,228 @@
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
+ const http = __importStar(require("node:http"));
37
+ const https = __importStar(require("node:https"));
38
+ const crypto = __importStar(require("node:crypto"));
39
+ const copilot_token_1 = require("./copilot-token");
40
+ let tokenManager = null;
41
+ function logWorkerEvent(message) {
42
+ process.stderr.write(`[${new Date().toISOString()}] ${message}\n`);
43
+ }
44
+ async function main() {
45
+ const provider = process.env.CODEX_SWITCH_BRIDGE_PROVIDER ?? "copilot";
46
+ const host = process.env.CODEX_SWITCH_BRIDGE_HOST ?? "127.0.0.1";
47
+ const port = Number(process.env.CODEX_SWITCH_BRIDGE_PORT ?? "41415");
48
+ const localApiKey = process.env.CODEX_SWITCH_BRIDGE_API_KEY ?? "";
49
+ const toolHomeDir = process.env.CODEX_SWITCH_TOOL_HOME_DIR || undefined;
50
+ const staticCopilotToken = process.env.CODEX_SWITCH_BRIDGE_COPILOT_TOKEN || undefined;
51
+ logWorkerEvent(`worker startup provider=${provider} host=${host} port=${String(port)}`);
52
+ if (staticCopilotToken) {
53
+ tokenManager = (0, copilot_token_1.createStaticTokenManager)(staticCopilotToken);
54
+ logWorkerEvent("copilot token acquired (static), api base: https://api.githubcopilot.com");
55
+ }
56
+ else {
57
+ const githubPat = process.env.CODEX_SWITCH_GITHUB_TOKEN || (0, copilot_token_1.readGithubToken)(toolHomeDir);
58
+ if (!githubPat) {
59
+ throw new Error("No GitHub token found. Run `codexs login copilot` first.");
60
+ }
61
+ tokenManager = (0, copilot_token_1.createTokenManager)(githubPat);
62
+ await tokenManager.getToken();
63
+ logWorkerEvent(`copilot token acquired, api base: ${tokenManager.getApiBaseUrl()}`);
64
+ }
65
+ const server = http.createServer(async (req, res) => {
66
+ try {
67
+ await handleRequest(req, res, localApiKey);
68
+ }
69
+ catch (error) {
70
+ if (!res.headersSent) {
71
+ res.writeHead(500, { "content-type": "application/json" });
72
+ res.end(JSON.stringify({ error: { message: error instanceof Error ? error.message : String(error) } }));
73
+ }
74
+ }
75
+ });
76
+ const stopWorker = () => {
77
+ logWorkerEvent(`worker shutdown provider=${provider}`);
78
+ tokenManager?.stop();
79
+ server.close();
80
+ process.exit(0);
81
+ };
82
+ process.once("SIGINT", stopWorker);
83
+ process.once("SIGTERM", stopWorker);
84
+ await new Promise((resolve, reject) => {
85
+ server.once("error", reject);
86
+ server.listen(port, host, () => {
87
+ server.off("error", reject);
88
+ resolve();
89
+ });
90
+ });
91
+ logWorkerEvent(`worker ready provider=${provider} host=${host} port=${String(port)}`);
92
+ }
93
+ async function handleRequest(req, res, localApiKey) {
94
+ const method = req.method ?? "GET";
95
+ const url = req.url ?? "/";
96
+ if (method === "GET" && url === "/healthz") {
97
+ res.writeHead(200, { "content-type": "application/json" });
98
+ res.end(JSON.stringify({ ok: true }));
99
+ return;
100
+ }
101
+ if (!isAuthorized(req, localApiKey)) {
102
+ res.writeHead(401, { "content-type": "application/json" });
103
+ res.end(JSON.stringify({ error: { message: "Unauthorized" } }));
104
+ return;
105
+ }
106
+ if (method === "GET" && url === "/v1/models") {
107
+ await proxyGet("/models", res);
108
+ return;
109
+ }
110
+ if (method === "POST" && url === "/v1/chat/completions") {
111
+ await proxyPost("/chat/completions", req, res);
112
+ return;
113
+ }
114
+ if (method === "POST" && url === "/v1/responses") {
115
+ await proxyPost("/responses", req, res);
116
+ return;
117
+ }
118
+ res.writeHead(404, { "content-type": "application/json" });
119
+ res.end(JSON.stringify({ error: { message: "Not found" } }));
120
+ }
121
+ async function proxyGet(upstreamPath, res) {
122
+ const copilotToken = await tokenManager.getToken();
123
+ const apiBase = tokenManager.getApiBaseUrl();
124
+ const targetUrl = new URL(upstreamPath, apiBase);
125
+ const headers = (0, copilot_token_1.getCopilotRequestHeaders)(copilotToken);
126
+ const upstreamRes = await httpsRequest({
127
+ method: "GET",
128
+ url: targetUrl,
129
+ headers,
130
+ });
131
+ res.writeHead(upstreamRes.statusCode, filterResponseHeaders(upstreamRes.headers));
132
+ upstreamRes.pipe(res);
133
+ }
134
+ async function proxyPost(upstreamPath, req, res) {
135
+ const body = await readRequestBody(req);
136
+ const copilotToken = await tokenManager.getToken();
137
+ const apiBase = tokenManager.getApiBaseUrl();
138
+ const targetUrl = new URL(upstreamPath, apiBase);
139
+ const requestId = crypto.randomUUID();
140
+ const headers = (0, copilot_token_1.getCopilotRequestHeaders)(copilotToken, requestId);
141
+ // Preserve content-length for the body
142
+ headers["content-length"] = Buffer.byteLength(body).toString();
143
+ const upstreamRes = await httpsRequest({
144
+ method: "POST",
145
+ url: targetUrl,
146
+ headers,
147
+ body,
148
+ });
149
+ // If upstream returned 401, try refreshing token once and retry
150
+ if (upstreamRes.statusCode === 401) {
151
+ upstreamRes.resume();
152
+ logWorkerEvent("upstream 401, invalidating token and retrying");
153
+ tokenManager.invalidate();
154
+ const freshToken = await tokenManager.getToken();
155
+ const retryHeaders = (0, copilot_token_1.getCopilotRequestHeaders)(freshToken, crypto.randomUUID());
156
+ retryHeaders["content-length"] = Buffer.byteLength(body).toString();
157
+ const retryRes = await httpsRequest({
158
+ method: "POST",
159
+ url: targetUrl,
160
+ headers: retryHeaders,
161
+ body,
162
+ });
163
+ res.writeHead(retryRes.statusCode, filterResponseHeaders(retryRes.headers));
164
+ retryRes.pipe(res);
165
+ return;
166
+ }
167
+ res.writeHead(upstreamRes.statusCode, filterResponseHeaders(upstreamRes.headers));
168
+ upstreamRes.pipe(res);
169
+ }
170
+ function httpsRequest(args) {
171
+ return new Promise((resolve, reject) => {
172
+ const req = https.request({
173
+ hostname: args.url.hostname,
174
+ port: args.url.port || 443,
175
+ path: args.url.pathname + args.url.search,
176
+ method: args.method,
177
+ headers: args.headers,
178
+ }, (res) => {
179
+ resolve(res);
180
+ });
181
+ req.on("error", reject);
182
+ if (args.body) {
183
+ req.write(args.body);
184
+ }
185
+ req.end();
186
+ });
187
+ }
188
+ function readRequestBody(req) {
189
+ return new Promise((resolve, reject) => {
190
+ const chunks = [];
191
+ req.on("data", (chunk) => chunks.push(chunk));
192
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
193
+ req.on("error", reject);
194
+ });
195
+ }
196
+ function filterResponseHeaders(headers) {
197
+ const filtered = {};
198
+ const passthrough = ["content-type", "transfer-encoding", "x-request-id"];
199
+ for (const key of passthrough) {
200
+ if (headers[key]) {
201
+ filtered[key] = headers[key];
202
+ }
203
+ }
204
+ // Always allow cache-control for SSE
205
+ if (headers["cache-control"]) {
206
+ filtered["cache-control"] = headers["cache-control"];
207
+ }
208
+ return filtered;
209
+ }
210
+ function isAuthorized(req, expectedApiKey) {
211
+ const authorization = req.headers.authorization;
212
+ if (!authorization || !authorization.startsWith("Bearer ")) {
213
+ return false;
214
+ }
215
+ return authorization.slice("Bearer ".length) === expectedApiKey;
216
+ }
217
+ if (require.main === module) {
218
+ process.on("uncaughtException", (error) => {
219
+ logWorkerEvent(`worker uncaught exception: ${error.message}`);
220
+ });
221
+ process.on("unhandledRejection", (reason) => {
222
+ logWorkerEvent(`worker unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
223
+ });
224
+ void main().catch((error) => {
225
+ logWorkerEvent(`worker startup failure: ${error instanceof Error ? error.message : String(error)}`);
226
+ process.exit(1);
227
+ });
228
+ }
@@ -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,17 @@
1
+ # codex-switch v0.1.5 Design
2
+
3
+ `0.1.5` is a Copilot Bridge process-visibility and redaction patch release.
4
+
5
+ ## Design Notes
6
+
7
+ - `CopilotBridgeRuntimeEvent` is the stable internal boundary between Copilot SDK session events and the OpenAI-compatible bridge surface.
8
+ - Copilot sessions are created with `streaming: true` and register the generic `session.on("event", handler)` listener alongside the existing `data`, `message`, and `delta` text compatibility listeners.
9
+ - The adapter maps known SDK event names into bridge runtime events for assistant intent, assistant message deltas, assistant reasoning deltas, tool lifecycle, permission lifecycle, user-input requests, exit-plan-mode requests, session idle, and session errors.
10
+ - `assistant.message_delta` is also forwarded as a normal text delta so final response streaming remains compatible with existing Chat Completions and Responses text paths.
11
+ - The bridge worker forwards adapter runtime events through `onRuntimeEvent`, separate from `onTextDelta` and `onTextDone`.
12
+ - Responses streaming projects non-text runtime process events as completed assistant message items with `phase: "commentary"`.
13
+ - Reasoning deltas project as `response.reasoning_summary_part.added` followed by `response.reasoning_summary_text.delta`.
14
+ - Unknown SDK events become `session.unknown`, are omitted from the UI projection path, and are logged with bounded summaries.
15
+ - Unknown summaries redact sensitive key names and obvious token/API-key-like values before truncation.
16
+ - Chat Completions streaming only wires text deltas, so Responses-only commentary events stay out of Chat Completions streams.
17
+ - Internal Copilot tool lifecycle events remain process visibility signals only; they are not emitted as Codex/OpenAI function calls or tool calls.
@@ -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.
@@ -0,0 +1,42 @@
1
+ # codex-switch v0.1.5 PRD
2
+
3
+ ## Version
4
+
5
+ - Version line: `0.1.5`
6
+ - Target repository package version: `0.1.5`
7
+
8
+ ## Summary
9
+
10
+ `0.1.5` is a Copilot Bridge process-visibility and redaction patch release. It surfaces assistant progress, reasoning summaries, tool lifecycle, permission, user-input, and exit-plan-mode signals through the existing bridge stream while keeping the provider command surface unchanged.
11
+
12
+ ## Required Outcome
13
+
14
+ - Copilot SDK streaming sessions must subscribe to the generic session event channel and preserve existing text-delta compatibility listeners.
15
+ - Known SDK process events must map into a stable bridge runtime-event contract instead of leaking SDK-specific shapes.
16
+ - Responses streaming must surface process/status updates as commentary items and reasoning/progress updates as reasoning summary events.
17
+ - Chat Completions streaming must continue to receive text only and must not receive Responses-only commentary events.
18
+ - Unknown SDK events must be ignored by the UI projection path while logging bounded, redacted summaries for diagnostics.
19
+ - Adapter-level tests must cover raw SDK session event normalization, not only bridge-server projection of already-normalized events.
20
+
21
+ ## Release Scope
22
+
23
+ - Copilot adapter runtime event contract and SDK event normalization.
24
+ - Bridge worker forwarding of adapter runtime events into request handlers.
25
+ - Responses streaming projection for commentary and reasoning summary events.
26
+ - Unknown-event truncation and redaction hardening.
27
+ - Focused regression coverage for raw SDK event mapping and bridge stream projection.
28
+
29
+ ## Non-Goals
30
+
31
+ - No new provider families.
32
+ - No migration or backward-compatibility shims.
33
+ - No expansion of direct-provider workflows.
34
+ - No conversion of internal Copilot tool lifecycle events into Codex/OpenAI function-call or tool-call payloads.
35
+
36
+ ## Release Acceptance
37
+
38
+ - `npm test` passes with adapter-level raw event normalization coverage.
39
+ - Responses streaming includes commentary and reasoning summary process events.
40
+ - Final assistant text still streams normally.
41
+ - Unknown events are redacted, truncated, logged for diagnostics, and ignored by the UI projection path.
42
+ - Chat Completions streaming does not emit Responses-only commentary events.
@@ -2,7 +2,7 @@
2
2
 
3
3
  This guide records the current `0.1.x` verification contract for release and review work.
4
4
 
5
- The current repository line is `0.1.4` and remains an unreleased development line until an explicit release task says otherwise.
5
+ The current repository line is `0.1.5` and remains an unreleased development line until an explicit release task says otherwise.
6
6
 
7
7
  ## Required checks
8
8
 
@@ -25,7 +25,7 @@ node dist/cli.js --version
25
25
  - `--json` envelope: top-level `ok`, `command`, `data`, `warnings`, and `error` must remain stable
26
26
  - `migrate`: advanced adopt helper only
27
27
  - `setup`: deprecated entry only
28
- - Release hygiene: `package.json`, `package-lock.json`, current-line docs, changelog top entry, and current PRD/Design fact sources must agree on the `0.1.4` development line
28
+ - Release hygiene: `package.json`, `package-lock.json`, current-line docs, changelog top entry, and current PRD/Design fact sources must agree on the `0.1.5` development line
29
29
 
30
30
  ## Fixture guidance
31
31