@textcortex/zenocode 0.1.7 → 0.1.9

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 CHANGED
@@ -1,7 +1,19 @@
1
1
  {
2
2
  "name": "@textcortex/zenocode",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Secure, EU-hosted coding agent for TextCortex customers that runs in your terminal, edits files, runs scripts, and more.",
5
+ "keywords": [
6
+ "ai",
7
+ "agent",
8
+ "coding-agent",
9
+ "terminal",
10
+ "cli",
11
+ "developer-tools",
12
+ "textcortex",
13
+ "secure",
14
+ "eu-hosted",
15
+ "gdpr"
16
+ ],
5
17
  "private": false,
6
18
  "license": "UNLICENSED",
7
19
  "type": "module",
@@ -26,6 +26,7 @@ const legacyRuntimeCredentialsPath = path.join(
26
26
  legacyRuntimeDir,
27
27
  "credentials.json",
28
28
  );
29
+ const logoutMarkerPath = path.join(runtimeDir, "logout-marker.json");
29
30
  const modelsPath = path.join(runtimeDir, "models.json");
30
31
  const configPath = path.join(runtimeDir, "opencode.jsonc");
31
32
  const localBaseUrlDefault = "http://127.0.0.1:8080";
@@ -45,8 +46,8 @@ const opencodeBinaryPath =
45
46
  process.env.ZENOCODE_OPENCODE_BIN_PATH ||
46
47
  process.env.CODECORTEX_OPENCODE_BIN_PATH ||
47
48
  "";
48
- const oauthInitiatePath = "/internal/v1/fastapi/codecortex/oauth2/initiate";
49
- const oauthTokenPath = "/internal/v1/fastapi/codecortex/oauth2/token";
49
+ const oauthInitiatePath = "/internal/v1/fastapi/zenocode/oauth2/initiate";
50
+ const oauthTokenPath = "/internal/v1/fastapi/zenocode/oauth2/token";
50
51
  const defaultOrder = [
51
52
  "kimi-k2-5-thinking",
52
53
  "glm-5",
@@ -63,12 +64,15 @@ const devCredentialFiles =
63
64
  path.join(process.cwd(), ".credentials.json"),
64
65
  ]
65
66
  : [];
66
- const credentialFiles = [
67
+ const runtimeCredentialFiles = [
67
68
  runtimeCredentialsPath,
68
69
  legacyRuntimeCredentialsPath,
70
+ ].filter((value, index, values) => values.indexOf(value) === index);
71
+ const fallbackCredentialFiles = [
69
72
  path.join(os.homedir(), ".credentials.json"),
70
73
  ...devCredentialFiles,
71
74
  ].filter((value, index, values) => values.indexOf(value) === index);
75
+ const credentialFiles = [...runtimeCredentialFiles, ...fallbackCredentialFiles];
72
76
 
73
77
  function _decodeEscapedLogoLine(line) {
74
78
  return line.replace(/\\u([0-9a-fA-F]{4})/g, (_, codePoint) =>
@@ -116,6 +120,28 @@ export async function writePrivateJsonFile(filePath, payload) {
116
120
  }
117
121
  }
118
122
 
123
+ async function clearLogoutMarker() {
124
+ try {
125
+ await fs.unlink(logoutMarkerPath);
126
+ } catch (error) {
127
+ if (error?.code !== "ENOENT") {
128
+ throw error;
129
+ }
130
+ }
131
+ }
132
+
133
+ async function writeLogoutMarker() {
134
+ await ensurePrivateDirectory(path.dirname(logoutMarkerPath));
135
+ await fs.writeFile(
136
+ logoutMarkerPath,
137
+ `${JSON.stringify({ logged_out_at: new Date().toISOString() }, null, 2)}\n`,
138
+ { encoding: "utf-8", mode: privateFileMode },
139
+ );
140
+ if (process.platform !== "win32") {
141
+ await fs.chmod(logoutMarkerPath, privateFileMode);
142
+ }
143
+ }
144
+
119
145
  function extractTokenFromCredentialPayload(parsed) {
120
146
  if (!parsed) return null;
121
147
  if (typeof parsed?.access_token === "string" && parsed.access_token) {
@@ -150,13 +176,191 @@ function extractBaseUrlFromCredentialPayload(parsed) {
150
176
  return null;
151
177
  }
152
178
 
179
+ export function resolveLoginSuccessIdentifier(tokenData) {
180
+ const authId =
181
+ typeof tokenData?.auth_id === "string" ? tokenData.auth_id.trim() : "";
182
+ return authId || "unknown";
183
+ }
184
+
185
+ function _isCredentialEntry(entry) {
186
+ return Boolean(
187
+ entry &&
188
+ typeof entry === "object" &&
189
+ !Array.isArray(entry) &&
190
+ (typeof entry.access_token === "string" ||
191
+ typeof entry.refresh_token === "string"),
192
+ );
193
+ }
194
+
195
+ function _selectCredentialEntry(parsed) {
196
+ if (_isCredentialEntry(parsed)) {
197
+ return {
198
+ entry: parsed,
199
+ apply(updatedEntry) {
200
+ return { ...parsed, ...updatedEntry };
201
+ },
202
+ };
203
+ }
204
+
205
+ if (Array.isArray(parsed?.accounts)) {
206
+ const index = parsed.accounts.findIndex((entry) => _isCredentialEntry(entry));
207
+ if (index !== -1) {
208
+ return {
209
+ entry: parsed.accounts[index],
210
+ apply(updatedEntry) {
211
+ const accounts = [...parsed.accounts];
212
+ accounts[index] = { ...accounts[index], ...updatedEntry };
213
+ return { ...parsed, accounts };
214
+ },
215
+ };
216
+ }
217
+ }
218
+
219
+ if (Array.isArray(parsed)) {
220
+ const index = parsed.findIndex((entry) => _isCredentialEntry(entry));
221
+ if (index !== -1) {
222
+ return {
223
+ entry: parsed[index],
224
+ apply(updatedEntry) {
225
+ const next = [...parsed];
226
+ next[index] = { ...next[index], ...updatedEntry };
227
+ return next;
228
+ },
229
+ };
230
+ }
231
+ }
232
+
233
+ return null;
234
+ }
235
+
236
+ async function loadStoredCredentialState(filePath) {
237
+ const parsed = await readJson(filePath);
238
+ const selected = _selectCredentialEntry(parsed);
239
+ if (!selected) {
240
+ return null;
241
+ }
242
+ return {
243
+ filePath,
244
+ parsed,
245
+ entry: selected.entry,
246
+ apply: selected.apply,
247
+ };
248
+ }
249
+
250
+ async function resolveCredentialFiles() {
251
+ if (await _pathExists(logoutMarkerPath)) {
252
+ return runtimeCredentialFiles;
253
+ }
254
+ return credentialFiles;
255
+ }
256
+
257
+ async function findStoredCredentialState() {
258
+ for (const filePath of await resolveCredentialFiles()) {
259
+ const state = await loadStoredCredentialState(filePath);
260
+ if (state) {
261
+ return state;
262
+ }
263
+ }
264
+ return null;
265
+ }
266
+
267
+ function _decodeBase64Url(value) {
268
+ const normalized = value.replaceAll("-", "+").replaceAll("_", "/");
269
+ const padding = "=".repeat((4 - (normalized.length % 4)) % 4);
270
+ return Buffer.from(`${normalized}${padding}`, "base64").toString("utf-8");
271
+ }
272
+
273
+ function extractAccessTokenExpiry(accessToken) {
274
+ try {
275
+ const [, payloadSegment] = String(accessToken || "").split(".");
276
+ if (!payloadSegment) return null;
277
+ const payload = JSON.parse(_decodeBase64Url(payloadSegment));
278
+ const exp = Number(payload?.exp);
279
+ if (!Number.isFinite(exp) || exp <= 0) {
280
+ return null;
281
+ }
282
+ return new Date(exp * 1000).toISOString();
283
+ } catch {
284
+ return null;
285
+ }
286
+ }
287
+
288
+ async function exchangeRefreshToken(baseUrl, refreshToken) {
289
+ const refreshUrl = new URL("/auth/token/refresh/", baseUrl).toString();
290
+ const { response, payload, text } = await requestJson(refreshUrl, {
291
+ method: "POST",
292
+ headers: {
293
+ Authorization: `Bearer ${refreshToken}`,
294
+ Accept: "application/json",
295
+ "Content-Type": "application/json",
296
+ },
297
+ });
298
+
299
+ if (!response.ok) {
300
+ const message = extractErrorMessage(payload, text || "request failed");
301
+ throw new Error(`Token refresh failed (${response.status}): ${message}`);
302
+ }
303
+
304
+ const data = unwrapData(payload);
305
+ if (!data?.access_token) {
306
+ throw new Error("Token refresh failed: invalid response payload.");
307
+ }
308
+
309
+ return data;
310
+ }
311
+
312
+ async function refreshStoredCredentials(baseUrl) {
313
+ const state = await findStoredCredentialState();
314
+ const refreshToken =
315
+ typeof state?.entry?.refresh_token === "string" && state.entry.refresh_token
316
+ ? state.entry.refresh_token
317
+ : null;
318
+
319
+ if (!state || !refreshToken) {
320
+ throw new Error("No refresh token available.");
321
+ }
322
+
323
+ const resolvedBaseUrl =
324
+ process.env.TEXTCORTEX_BASE_URL ||
325
+ (typeof state.entry.base_url === "string" && state.entry.base_url) ||
326
+ baseUrl;
327
+ if (!resolvedBaseUrl) {
328
+ throw new Error("Missing base URL for token refresh.");
329
+ }
330
+
331
+ const refreshed = await exchangeRefreshToken(resolvedBaseUrl, refreshToken);
332
+ const updatedEntry = {
333
+ ...state.entry,
334
+ base_url: resolvedBaseUrl,
335
+ access_token: refreshed.access_token,
336
+ refresh_token:
337
+ typeof refreshed.refresh_token === "string" && refreshed.refresh_token
338
+ ? refreshed.refresh_token
339
+ : refreshToken,
340
+ auth_id: refreshed.auth_id || state.entry.auth_id || null,
341
+ email: refreshed.email || state.entry.email || null,
342
+ expires_at:
343
+ refreshed.expires_at ||
344
+ extractAccessTokenExpiry(refreshed.access_token) ||
345
+ state.entry.expires_at ||
346
+ null,
347
+ updated_at: new Date().toISOString(),
348
+ };
349
+ await writePrivateJsonFile(state.filePath, state.apply(updatedEntry));
350
+
351
+ return {
352
+ token: updatedEntry.access_token,
353
+ baseUrl: resolvedBaseUrl,
354
+ };
355
+ }
356
+
153
357
  async function resolveToken() {
154
358
  const envToken = process.env.TEXTCORTEX_API_KEY || process.env.TEXTCORTEX_API_TOKEN;
155
359
  if (envToken) return envToken;
156
360
 
157
- for (const filePath of credentialFiles) {
158
- const parsed = await readJson(filePath);
159
- const token = extractTokenFromCredentialPayload(parsed);
361
+ for (const filePath of await resolveCredentialFiles()) {
362
+ const state = await loadStoredCredentialState(filePath);
363
+ const token = extractTokenFromCredentialPayload(state?.parsed);
160
364
  if (token) return token;
161
365
  }
162
366
 
@@ -195,7 +399,7 @@ export function buildOpenCodeConfig({ baseUrl, providerID, model, smallModel })
195
399
  [providerID]: {
196
400
  name: "Zenocode",
197
401
  options: {
198
- baseURL: new URL("/internal/v1/fastapi/codecortex/v1", baseUrl).toString(),
402
+ baseURL: new URL("/internal/v1/fastapi/zenocode/v1", baseUrl).toString(),
199
403
  },
200
404
  },
201
405
  // Older fallback opencode-ai builds can load the Codex auth plugin when
@@ -249,7 +453,7 @@ async function requestJson(url, init) {
249
453
  }
250
454
 
251
455
  async function prepareRuntime(baseUrl, token) {
252
- const modelsUrl = new URL("/internal/v1/fastapi/codecortex/models/api.json", baseUrl).toString();
456
+ const modelsUrl = new URL("/internal/v1/fastapi/zenocode/models/api.json", baseUrl).toString();
253
457
  const { response, payload, text } = await requestJson(modelsUrl, {
254
458
  headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
255
459
  });
@@ -441,10 +645,14 @@ async function saveRuntimeCredentials(baseUrl, tokenData) {
441
645
  refresh_token: tokenData.refresh_token,
442
646
  auth_id: tokenData.auth_id || null,
443
647
  email: tokenData.email || null,
444
- expires_at: tokenData.expires_at || null,
648
+ expires_at:
649
+ tokenData.expires_at ||
650
+ extractAccessTokenExpiry(tokenData.access_token) ||
651
+ null,
445
652
  updated_at: new Date().toISOString(),
446
653
  };
447
654
  await writePrivateJsonFile(runtimeCredentialsPath, payload);
655
+ await clearLogoutMarker();
448
656
  }
449
657
 
450
658
  async function runLoginCommand(baseUrl, args) {
@@ -503,17 +711,14 @@ async function runLoginCommand(baseUrl, args) {
503
711
  );
504
712
  await saveRuntimeCredentials(resolvedBaseUrl, tokenData);
505
713
 
506
- const account = tokenData.email || tokenData.auth_id || "unknown";
714
+ const account = resolveLoginSuccessIdentifier(tokenData);
507
715
  console.log(`Login successful for ${account}.`);
508
716
  console.log(`Credentials saved to ${runtimeCredentialsPath}`);
509
717
  }
510
718
 
511
719
  async function runLogoutCommand() {
512
720
  let removedAnyCredentials = false;
513
- for (const credentialsPath of [
514
- runtimeCredentialsPath,
515
- legacyRuntimeCredentialsPath,
516
- ].filter((value, index, values) => values.indexOf(value) === index)) {
721
+ for (const credentialsPath of runtimeCredentialFiles) {
517
722
  try {
518
723
  await fs.unlink(credentialsPath);
519
724
  removedAnyCredentials = true;
@@ -525,8 +730,15 @@ async function runLogoutCommand() {
525
730
  }
526
731
  }
527
732
 
528
- if (removedAnyCredentials) {
529
- console.log("Zenocode credentials removed.");
733
+ const fallbackCredentialsPresent = (
734
+ await Promise.all(
735
+ fallbackCredentialFiles.map((credentialsPath) => _pathExists(credentialsPath)),
736
+ )
737
+ ).some(Boolean);
738
+
739
+ if (removedAnyCredentials || fallbackCredentialsPresent) {
740
+ await writeLogoutMarker();
741
+ console.log("Zenocode session cleared.");
530
742
  return;
531
743
  }
532
744
 
@@ -915,6 +1127,20 @@ async function _ensurePatchedOpencodeDlxBinaries(packageName, runner, options) {
915
1127
  return _preparePinnedRuntimeBinary(binaryCandidates);
916
1128
  }
917
1129
 
1130
+ /**
1131
+ * Mirror direct runtime launches so fallback package runners stay interactive.
1132
+ */
1133
+ export function buildPackageLauncherChildOptions(options, pinnedRuntimePath) {
1134
+ return {
1135
+ ...options,
1136
+ stdio: "inherit",
1137
+ env: {
1138
+ ...(options.env || {}),
1139
+ ...(pinnedRuntimePath ? { OPENCODE_BIN_PATH: pinnedRuntimePath } : {}),
1140
+ },
1141
+ };
1142
+ }
1143
+
918
1144
  async function runPackageLauncher(packageName, args, options) {
919
1145
  const runners = [
920
1146
  { command: _runnerCommand("pnpm"), args: ["dlx", packageName, ...args] },
@@ -932,13 +1158,7 @@ async function runPackageLauncher(packageName, args, options) {
932
1158
  }
933
1159
  }
934
1160
 
935
- const childOptions = {
936
- ...options,
937
- env: {
938
- ...(options.env || {}),
939
- ...(pinnedRuntimePath ? { OPENCODE_BIN_PATH: pinnedRuntimePath } : {}),
940
- },
941
- };
1161
+ const childOptions = buildPackageLauncherChildOptions(options, pinnedRuntimePath);
942
1162
 
943
1163
  const result = await runChild(runner.command, runner.args, childOptions);
944
1164
  if (result.signal) {
@@ -1005,14 +1225,19 @@ export async function runRuntimeWithSessionRecovery({
1005
1225
  token,
1006
1226
  childOptions,
1007
1227
  canAutoLoginRuntime,
1228
+ refreshTokenFn = refreshStoredCredentials,
1008
1229
  runLogin = runLoginCommand,
1009
1230
  resolveTokenFn = resolveToken,
1010
1231
  resolveStoredBaseUrlFn = resolveStoredBaseUrl,
1011
1232
  prepareRuntimeFn = prepareRuntime,
1012
1233
  launchRuntimeFn,
1234
+ maxRecoveryAttempts = 3,
1235
+ recoveryDelayMs = 500,
1236
+ sleepFn = sleep,
1013
1237
  }) {
1014
1238
  let activeBaseUrl = baseUrl;
1015
1239
  let activeToken = token;
1240
+ let recoveryAttempts = 0;
1016
1241
 
1017
1242
  while (true) {
1018
1243
  const result = await launchRuntimeFn({
@@ -1031,8 +1256,30 @@ export async function runRuntimeWithSessionRecovery({
1031
1256
  return result;
1032
1257
  }
1033
1258
 
1034
- if (!canAutoLoginRuntime) {
1035
- return result;
1259
+ if (recoveryAttempts >= maxRecoveryAttempts) {
1260
+ throw new Error(
1261
+ `Zenocode session recovery failed after ${maxRecoveryAttempts} attempts. Run \`zenocode login\` and try again.`,
1262
+ );
1263
+ }
1264
+ recoveryAttempts += 1;
1265
+
1266
+ try {
1267
+ const refreshed = await refreshTokenFn(activeBaseUrl);
1268
+ activeToken = refreshed.token;
1269
+ activeBaseUrl = refreshed.baseUrl;
1270
+ await prepareRuntimeFn(activeBaseUrl, activeToken);
1271
+ const retryDelayMs = Math.min(
1272
+ Math.max(recoveryDelayMs, 0) * recoveryAttempts,
1273
+ 3_000,
1274
+ );
1275
+ if (retryDelayMs > 0) {
1276
+ await sleepFn(retryDelayMs);
1277
+ }
1278
+ continue;
1279
+ } catch {
1280
+ if (!canAutoLoginRuntime) {
1281
+ return result;
1282
+ }
1036
1283
  }
1037
1284
 
1038
1285
  console.log("Zenocode session expired. Starting login flow...\n");
@@ -1040,6 +1287,13 @@ export async function runRuntimeWithSessionRecovery({
1040
1287
  activeToken = await resolveTokenFn();
1041
1288
  activeBaseUrl = process.env.TEXTCORTEX_BASE_URL || (await resolveStoredBaseUrlFn()) || activeBaseUrl;
1042
1289
  await prepareRuntimeFn(activeBaseUrl, activeToken);
1290
+ const retryDelayMs = Math.min(
1291
+ Math.max(recoveryDelayMs, 0) * recoveryAttempts,
1292
+ 3_000,
1293
+ );
1294
+ if (retryDelayMs > 0) {
1295
+ await sleepFn(retryDelayMs);
1296
+ }
1043
1297
  }
1044
1298
  }
1045
1299
 
@@ -1214,8 +1468,19 @@ async function prepareRuntimeWithAutoLogin(baseUrl, token, args) {
1214
1468
  if (!isExpiredSessionError(error)) {
1215
1469
  throw error;
1216
1470
  }
1217
- if (!canAutoLogin(args)) {
1218
- throw new Error("Zenocode session expired. Run `zenocode login` and try again.");
1471
+
1472
+ try {
1473
+ const refreshed = await refreshStoredCredentials(baseUrl);
1474
+ const model = await prepareRuntime(refreshed.baseUrl, refreshed.token);
1475
+ return {
1476
+ model,
1477
+ token: refreshed.token,
1478
+ baseUrl: refreshed.baseUrl,
1479
+ };
1480
+ } catch {
1481
+ if (!canAutoLogin(args)) {
1482
+ throw new Error("Zenocode session expired. Run `zenocode login` and try again.");
1483
+ }
1219
1484
  }
1220
1485
 
1221
1486
  console.log("Zenocode session expired. Starting login flow...\n");
@@ -1,4 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
+ import http from "node:http";
2
3
  import fs from "node:fs/promises";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
@@ -10,9 +11,11 @@ import {
10
11
  } from "./branding-patch.mjs";
11
12
  import {
12
13
  buildOpenCodeConfig,
14
+ buildPackageLauncherChildOptions,
13
15
  canRecoverRuntimeSessionFromTranscript,
14
16
  buildZenocodeBanner,
15
17
  chooseDefaults,
18
+ resolveLoginSuccessIdentifier,
16
19
  runRuntimeWithSessionRecovery,
17
20
  writePrivateJsonFile,
18
21
  } from "./run-zenocode.mjs";
@@ -44,6 +47,23 @@ test("buildOpenCodeConfig includes an openai stub for fallback runtime auth plug
44
47
  assert.deepEqual(config.provider.openai.models, {});
45
48
  });
46
49
 
50
+ test("buildPackageLauncherChildOptions keeps fallback package launchers attached to the terminal", () => {
51
+ const childOptions = buildPackageLauncherChildOptions(
52
+ {
53
+ cwd: "/tmp/zenocode",
54
+ env: { TEXTCORTEX_API_KEY: "token-1" },
55
+ },
56
+ "/tmp/zenocode-runtime",
57
+ );
58
+
59
+ assert.equal(childOptions.stdio, "inherit");
60
+ assert.equal(childOptions.cwd, "/tmp/zenocode");
61
+ assert.deepEqual(childOptions.env, {
62
+ TEXTCORTEX_API_KEY: "token-1",
63
+ OPENCODE_BIN_PATH: "/tmp/zenocode-runtime",
64
+ });
65
+ });
66
+
47
67
  test("buildZenocodeBanner renders block logo art instead of plain text", () => {
48
68
  const banner = buildZenocodeBanner();
49
69
 
@@ -117,6 +137,22 @@ test("canRecoverRuntimeSessionFromTranscript detects expired session output", ()
117
137
  assert.equal(canRecoverRuntimeSessionFromTranscript(transcript), true);
118
138
  });
119
139
 
140
+ test("resolveLoginSuccessIdentifier avoids email fallback", () => {
141
+ assert.equal(
142
+ resolveLoginSuccessIdentifier({
143
+ auth_id: "auth_123",
144
+ email: "user@example.com",
145
+ }),
146
+ "auth_123",
147
+ );
148
+ assert.equal(
149
+ resolveLoginSuccessIdentifier({
150
+ email: "user@example.com",
151
+ }),
152
+ "unknown",
153
+ );
154
+ });
155
+
120
156
  test("runRuntimeWithSessionRecovery reruns login after runtime session expiry", async () => {
121
157
  const events = [];
122
158
  let launchCount = 0;
@@ -130,6 +166,10 @@ test("runRuntimeWithSessionRecovery reruns login after runtime session expiry",
130
166
  env: {},
131
167
  },
132
168
  canAutoLoginRuntime: true,
169
+ refreshTokenFn: async (baseUrl) => {
170
+ events.push(["refresh", baseUrl]);
171
+ throw new Error("refresh failed");
172
+ },
133
173
  runLogin: async (baseUrl, loginArgs) => {
134
174
  events.push(["login", baseUrl, loginArgs]);
135
175
  },
@@ -157,6 +197,7 @@ test("runRuntimeWithSessionRecovery reruns login after runtime session expiry",
157
197
 
158
198
  assert.deepEqual(events, [
159
199
  ["launch", 1, "token-1"],
200
+ ["refresh", "http://127.0.0.1:8080"],
160
201
  ["login", "http://127.0.0.1:8080", []],
161
202
  ["resolve-token"],
162
203
  ["resolve-base-url"],
@@ -166,11 +207,239 @@ test("runRuntimeWithSessionRecovery reruns login after runtime session expiry",
166
207
  assert.deepEqual(result, { code: 0, signal: null, expiredSession: false });
167
208
  });
168
209
 
169
- test("logout removes legacy credential fallbacks as well as Zenocode credentials", async (t) => {
210
+ test("runRuntimeWithSessionRecovery refreshes stored credentials before forcing login", async () => {
211
+ const events = [];
212
+ let launchCount = 0;
213
+
214
+ const result = await runRuntimeWithSessionRecovery({
215
+ args: ["run"],
216
+ baseUrl: "http://127.0.0.1:8080",
217
+ token: "token-1",
218
+ childOptions: {
219
+ cwd: process.cwd(),
220
+ env: {},
221
+ },
222
+ canAutoLoginRuntime: true,
223
+ refreshTokenFn: async (baseUrl) => {
224
+ events.push(["refresh", baseUrl]);
225
+ return {
226
+ token: "token-2",
227
+ baseUrl: "https://api.textcortex.com",
228
+ };
229
+ },
230
+ runLogin: async () => {
231
+ throw new Error("login should not be called");
232
+ },
233
+ prepareRuntimeFn: async (baseUrl, token) => {
234
+ events.push(["prepare", baseUrl, token]);
235
+ return "kimi-k2-5-thinking";
236
+ },
237
+ launchRuntimeFn: async ({ childOptions }) => {
238
+ launchCount += 1;
239
+ events.push(["launch", launchCount, childOptions.env.TEXTCORTEX_API_KEY]);
240
+ if (launchCount === 1) {
241
+ return { expiredSession: true };
242
+ }
243
+ return { code: 0, signal: null, expiredSession: false };
244
+ },
245
+ });
246
+
247
+ assert.deepEqual(events, [
248
+ ["launch", 1, "token-1"],
249
+ ["refresh", "http://127.0.0.1:8080"],
250
+ ["prepare", "https://api.textcortex.com", "token-2"],
251
+ ["launch", 2, "token-2"],
252
+ ]);
253
+ assert.deepEqual(result, { code: 0, signal: null, expiredSession: false });
254
+ });
255
+
256
+ test("runRuntimeWithSessionRecovery stops after repeated recovery failures", async () => {
257
+ const events = [];
258
+ let launchCount = 0;
259
+
260
+ await assert.rejects(
261
+ () =>
262
+ runRuntimeWithSessionRecovery({
263
+ args: ["run"],
264
+ baseUrl: "http://127.0.0.1:8080",
265
+ token: "token-1",
266
+ childOptions: {
267
+ cwd: process.cwd(),
268
+ env: {},
269
+ },
270
+ canAutoLoginRuntime: true,
271
+ maxRecoveryAttempts: 2,
272
+ sleepFn: async () => {
273
+ events.push(["sleep"]);
274
+ },
275
+ refreshTokenFn: async (baseUrl) => {
276
+ events.push(["refresh", baseUrl]);
277
+ throw new Error("refresh failed");
278
+ },
279
+ runLogin: async (baseUrl, loginArgs) => {
280
+ events.push(["login", baseUrl, loginArgs]);
281
+ },
282
+ resolveTokenFn: async () => {
283
+ events.push(["resolve-token"]);
284
+ return "token-2";
285
+ },
286
+ resolveStoredBaseUrlFn: async () => {
287
+ events.push(["resolve-base-url"]);
288
+ return "https://api.textcortex.com";
289
+ },
290
+ prepareRuntimeFn: async (baseUrl, token) => {
291
+ events.push(["prepare", baseUrl, token]);
292
+ return "kimi-k2-5-thinking";
293
+ },
294
+ launchRuntimeFn: async ({ childOptions }) => {
295
+ launchCount += 1;
296
+ events.push(["launch", launchCount, childOptions.env.TEXTCORTEX_API_KEY]);
297
+ return { expiredSession: true };
298
+ },
299
+ }),
300
+ /Zenocode session recovery failed after 2 attempts/,
301
+ );
302
+
303
+ assert.deepEqual(events, [
304
+ ["launch", 1, "token-1"],
305
+ ["refresh", "http://127.0.0.1:8080"],
306
+ ["login", "http://127.0.0.1:8080", []],
307
+ ["resolve-token"],
308
+ ["resolve-base-url"],
309
+ ["prepare", "https://api.textcortex.com", "token-2"],
310
+ ["sleep"],
311
+ ["launch", 2, "token-2"],
312
+ ["refresh", "https://api.textcortex.com"],
313
+ ["login", "https://api.textcortex.com", []],
314
+ ["resolve-token"],
315
+ ["resolve-base-url"],
316
+ ["prepare", "https://api.textcortex.com", "token-2"],
317
+ ["sleep"],
318
+ ["launch", 3, "token-2"],
319
+ ]);
320
+ });
321
+
322
+ test("prepare-only refreshes stored Zenocode credentials when the access token has expired", async (t) => {
323
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zenocode-refresh-"));
324
+ const zenocodeHome = path.join(tempDir, ".zenocode");
325
+ const credentialsPath = path.join(zenocodeHome, "credentials.json");
326
+ const scriptPath = new URL("./run-zenocode.mjs", import.meta.url);
327
+ let refreshCalls = 0;
328
+ let refreshedAuthHeader = null;
329
+ let modelAuthHeader = null;
330
+
331
+ t.after(async () => {
332
+ await fs.rm(tempDir, { recursive: true, force: true });
333
+ });
334
+
335
+ await fs.mkdir(zenocodeHome, { recursive: true });
336
+ await fs.writeFile(
337
+ credentialsPath,
338
+ JSON.stringify(
339
+ {
340
+ base_url: "http://127.0.0.1:1",
341
+ access_token: "expired-access",
342
+ refresh_token: "refresh-token",
343
+ expires_at: new Date(Date.now() - 60_000).toISOString(),
344
+ },
345
+ null,
346
+ 2,
347
+ ),
348
+ );
349
+
350
+ const server = http.createServer((req, res) => {
351
+ if (req.method === "POST" && req.url === "/auth/token/refresh/") {
352
+ refreshCalls += 1;
353
+ refreshedAuthHeader = req.headers.authorization || null;
354
+ res.writeHead(200, { "Content-Type": "application/json" });
355
+ res.end(
356
+ JSON.stringify({
357
+ access_token: "fresh-access",
358
+ refresh_token: "fresh-refresh",
359
+ }),
360
+ );
361
+ return;
362
+ }
363
+
364
+ if (
365
+ req.method === "GET" &&
366
+ req.url === "/internal/v1/fastapi/zenocode/models/api.json"
367
+ ) {
368
+ modelAuthHeader = req.headers.authorization || null;
369
+ if (modelAuthHeader === "Bearer expired-access") {
370
+ res.writeHead(401, { "Content-Type": "application/json" });
371
+ res.end(JSON.stringify({ detail: "Token has expired" }));
372
+ return;
373
+ }
374
+
375
+ res.writeHead(200, { "Content-Type": "application/json" });
376
+ res.end(
377
+ JSON.stringify({
378
+ textcortex: {
379
+ models: {
380
+ "kimi-k2-5-thinking": {},
381
+ "glm-5": {},
382
+ },
383
+ },
384
+ }),
385
+ );
386
+ return;
387
+ }
388
+
389
+ res.writeHead(404, { "Content-Type": "application/json" });
390
+ res.end(JSON.stringify({ detail: "not found" }));
391
+ });
392
+
393
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
394
+ const address = server.address();
395
+ const baseUrl = `http://127.0.0.1:${address.port}`;
396
+
397
+ t.after(async () => {
398
+ await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
399
+ });
400
+
401
+ const result = await new Promise((resolve, reject) => {
402
+ const child = spawn(
403
+ process.execPath,
404
+ [scriptPath.pathname, "--prepare-only"],
405
+ {
406
+ cwd: tempDir,
407
+ env: {
408
+ ...process.env,
409
+ ZENOCODE_HOME: zenocodeHome,
410
+ TEXTCORTEX_BASE_URL: baseUrl,
411
+ ZENOCODE_NO_BANNER: "1",
412
+ },
413
+ stdio: ["ignore", "pipe", "pipe"],
414
+ },
415
+ );
416
+ let stdout = "";
417
+ let stderr = "";
418
+ child.stdout.on("data", (chunk) => {
419
+ stdout += String(chunk);
420
+ });
421
+ child.stderr.on("data", (chunk) => {
422
+ stderr += String(chunk);
423
+ });
424
+ child.on("error", reject);
425
+ child.on("exit", (code, signal) => resolve({ code, signal, stdout, stderr }));
426
+ });
427
+
428
+ assert.equal(result.code, 0);
429
+ assert.equal(refreshCalls, 1);
430
+ assert.equal(refreshedAuthHeader, "Bearer refresh-token");
431
+ assert.equal(modelAuthHeader, "Bearer fresh-access");
432
+
433
+ const savedCredentials = JSON.parse(await fs.readFile(credentialsPath, "utf-8"));
434
+ assert.equal(savedCredentials.access_token, "fresh-access");
435
+ assert.equal(savedCredentials.refresh_token, "fresh-refresh");
436
+ });
437
+
438
+ test("logout removes runtime credentials and blocks shared fallback credentials", async (t) => {
170
439
  const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zenocode-logout-"));
171
440
  const zenocodeHome = path.join(tempDir, ".zenocode");
172
441
  const codecortexHome = path.join(tempDir, ".codecortex");
173
- const repoCredentialsPath = path.join(tempDir, ".credentials.json");
442
+ const homeCredentialsPath = path.join(tempDir, ".credentials.json");
174
443
  const runtimeCredentialsPath = path.join(zenocodeHome, "credentials.json");
175
444
  const legacyRuntimeCredentialsPath = path.join(codecortexHome, "credentials.json");
176
445
  const scriptPath = new URL("./run-zenocode.mjs", import.meta.url);
@@ -186,7 +455,7 @@ test("logout removes legacy credential fallbacks as well as Zenocode credentials
186
455
  legacyRuntimeCredentialsPath,
187
456
  JSON.stringify({ access_token: "legacy" }),
188
457
  );
189
- await fs.writeFile(repoCredentialsPath, JSON.stringify({ access_token: "repo" }));
458
+ await fs.writeFile(homeCredentialsPath, JSON.stringify({ access_token: "home" }));
190
459
 
191
460
  const result = await new Promise((resolve, reject) => {
192
461
  const child = spawn(
@@ -196,6 +465,7 @@ test("logout removes legacy credential fallbacks as well as Zenocode credentials
196
465
  cwd: tempDir,
197
466
  env: {
198
467
  ...process.env,
468
+ HOME: tempDir,
199
469
  ZENOCODE_HOME: zenocodeHome,
200
470
  CODECORTEX_HOME: codecortexHome,
201
471
  },
@@ -218,7 +488,38 @@ test("logout removes legacy credential fallbacks as well as Zenocode credentials
218
488
  await assert.rejects(fs.stat(runtimeCredentialsPath), { code: "ENOENT" });
219
489
  await assert.rejects(fs.stat(legacyRuntimeCredentialsPath), { code: "ENOENT" });
220
490
  assert.deepEqual(
221
- JSON.parse(await fs.readFile(repoCredentialsPath, "utf-8")),
222
- { access_token: "repo" },
491
+ JSON.parse(await fs.readFile(homeCredentialsPath, "utf-8")),
492
+ { access_token: "home" },
223
493
  );
494
+
495
+ const prepareOnlyResult = await new Promise((resolve, reject) => {
496
+ const child = spawn(
497
+ process.execPath,
498
+ [scriptPath.pathname, "--prepare-only"],
499
+ {
500
+ cwd: tempDir,
501
+ env: {
502
+ ...process.env,
503
+ HOME: tempDir,
504
+ ZENOCODE_HOME: zenocodeHome,
505
+ CODECORTEX_HOME: codecortexHome,
506
+ ZENOCODE_NO_BANNER: "1",
507
+ },
508
+ stdio: ["ignore", "pipe", "pipe"],
509
+ },
510
+ );
511
+ let stdout = "";
512
+ let stderr = "";
513
+ child.stdout.on("data", (chunk) => {
514
+ stdout += String(chunk);
515
+ });
516
+ child.stderr.on("data", (chunk) => {
517
+ stderr += String(chunk);
518
+ });
519
+ child.on("error", reject);
520
+ child.on("exit", (code, signal) => resolve({ code, signal, stdout, stderr }));
521
+ });
522
+
523
+ assert.equal(prepareOnlyResult.code, 1);
524
+ assert.match(prepareOnlyResult.stderr, /Missing API token/);
224
525
  });