@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 +13 -1
- package/scripts/run-zenocode.mjs +292 -27
- package/scripts/run-zenocode.test.mjs +306 -5
package/package.json
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@textcortex/zenocode",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
package/scripts/run-zenocode.mjs
CHANGED
|
@@ -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/
|
|
49
|
-
const oauthTokenPath = "/internal/v1/fastapi/
|
|
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
|
|
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
|
|
158
|
-
const
|
|
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/
|
|
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/
|
|
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:
|
|
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
|
|
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
|
-
|
|
529
|
-
|
|
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 (
|
|
1035
|
-
|
|
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
|
-
|
|
1218
|
-
|
|
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("
|
|
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
|
|
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(
|
|
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(
|
|
222
|
-
{ access_token: "
|
|
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
|
});
|