u-foo 2.5.8 → 2.5.10
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/README.md +2 -0
- package/README.zh-CN.md +3 -1
- package/bin/ufoo.js +6 -1
- package/bin/ukimi.js +70 -0
- package/package.json +2 -1
- package/src/agents/activity/activityDetector.js +7 -0
- package/src/agents/launch/launcher.js +6 -5
- package/src/agents/launch/readyDetector.js +21 -0
- package/src/agents/prompts/defaultBootstrap.js +20 -3
- package/src/agents/providers/credentials/index.js +7 -0
- package/src/agents/providers/credentials/kimi.js +342 -0
- package/src/agents/providers/directAuthStatus.js +94 -0
- package/src/app/chat/commandExecutor.js +10 -5
- package/src/app/chat/commands.js +2 -0
- package/src/app/chat/dashboardView.js +1 -0
- package/src/app/chat/multiWindow/index.js +88 -46
- package/src/app/chat/multiWindow/renderer.js +57 -8
- package/src/app/cli/run.js +5 -3
- package/src/code/nativeRunner.js +57 -6
- package/src/config.js +8 -0
- package/src/orchestration/groups/validateTemplate.js +1 -1
- package/src/runtime/daemon/groupOrchestrator.js +6 -0
- package/src/runtime/daemon/index.js +12 -3
- package/src/runtime/daemon/ops.js +7 -1
- package/src/runtime/daemon/providerSessions.js +41 -2
- package/src/tools/schemaFixtures.js +1 -1
- package/src/ui/format/index.js +14 -2
- package/src/ui/ink/ChatApp.js +321 -64
- package/src/ui/ink/chatReducer.js +32 -8
- package/src/ui/runInk.js +18 -9
package/README.md
CHANGED
|
@@ -61,6 +61,7 @@ Installed binaries:
|
|
|
61
61
|
| `uclaude` | Claude Code wrapper with ufoo bootstrap and bus identity. |
|
|
62
62
|
| `ucodex` | Codex wrapper with ufoo bootstrap and bus identity. |
|
|
63
63
|
| `uagy` | Antigravity wrapper with ufoo bootstrap and bus identity. |
|
|
64
|
+
| `ukimi` | Kimi Code wrapper with ufoo bootstrap and bus identity. |
|
|
64
65
|
| `ucode` | Native ufoo coding-agent CLI/TUI. |
|
|
65
66
|
|
|
66
67
|
## Quick Start
|
|
@@ -88,6 +89,7 @@ Or launch wrappers directly inside a project:
|
|
|
88
89
|
uclaude
|
|
89
90
|
ucodex
|
|
90
91
|
uagy
|
|
92
|
+
ukimi
|
|
91
93
|
ucode
|
|
92
94
|
```
|
|
93
95
|
|
package/README.zh-CN.md
CHANGED
|
@@ -59,6 +59,7 @@ npm link
|
|
|
59
59
|
| `uclaude` | Claude Code 包装器,注入 ufoo bootstrap 和 bus 身份。 |
|
|
60
60
|
| `ucodex` | Codex 包装器,注入 ufoo bootstrap 和 bus 身份。 |
|
|
61
61
|
| `uagy` | Antigravity 包装器,注入 ufoo bootstrap 和 bus 身份。 |
|
|
62
|
+
| `ukimi` | Kimi Code 包装器,注入 ufoo bootstrap 和 bus 身份。 |
|
|
62
63
|
| `ucode` | 原生 ufoo coding-agent CLI/TUI。 |
|
|
63
64
|
|
|
64
65
|
## 快速开始
|
|
@@ -86,6 +87,7 @@ ufoo
|
|
|
86
87
|
uclaude
|
|
87
88
|
ucodex
|
|
88
89
|
uagy
|
|
90
|
+
ukimi
|
|
89
91
|
ucode
|
|
90
92
|
```
|
|
91
93
|
|
|
@@ -155,7 +157,7 @@ ufoo -g
|
|
|
155
157
|
/open /path/to/project
|
|
156
158
|
```
|
|
157
159
|
|
|
158
|
-
`uclaude`、`ucodex`、`uagy`、`ucode` 这些直接包装器仍然可用,但 ufoo 的
|
|
160
|
+
`uclaude`、`ucodex`、`uagy`、`ukimi`、`ucode` 这些直接包装器仍然可用,但 ufoo 的
|
|
159
161
|
主要工作流是在 chat 里完成。
|
|
160
162
|
|
|
161
163
|
### 初始化与维护
|
package/bin/ufoo.js
CHANGED
|
@@ -143,6 +143,9 @@ async function main() {
|
|
|
143
143
|
} else if (agentType === "codex") {
|
|
144
144
|
scriptName = "ucodex.js";
|
|
145
145
|
displayName = "ucodex";
|
|
146
|
+
} else if (agentType === "kimi") {
|
|
147
|
+
scriptName = "ukimi.js";
|
|
148
|
+
displayName = "ukimi";
|
|
146
149
|
} else {
|
|
147
150
|
console.error(`Error: Unable to determine agent type for ${subscriberId}`);
|
|
148
151
|
process.exitCode = 1;
|
|
@@ -182,11 +185,13 @@ async function main() {
|
|
|
182
185
|
scriptName = "uclaude.js";
|
|
183
186
|
} else if (targetLower === "ucodex" || targetLower === "codex" || targetLower === "openai") {
|
|
184
187
|
scriptName = "ucodex.js";
|
|
188
|
+
} else if (targetLower === "ukimi" || targetLower === "kimi" || targetLower === "kimi-cli" || targetLower === "kimi-code") {
|
|
189
|
+
scriptName = "ukimi.js";
|
|
185
190
|
} else {
|
|
186
191
|
// Not a valid agent type - might be an offline agent nickname
|
|
187
192
|
console.error(`Error: Agent '${target}' is not online and is not a valid agent type`);
|
|
188
193
|
console.error("");
|
|
189
|
-
console.error("Valid agent types: ucode, uclaude, ucodex");
|
|
194
|
+
console.error("Valid agent types: ucode, uclaude, ucodex, ukimi");
|
|
190
195
|
console.error("");
|
|
191
196
|
console.error("To see online agents, run: ufoo bus status");
|
|
192
197
|
process.exitCode = 1;
|
package/bin/ukimi.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ukimi: Launch Kimi Code CLI (kimi) and auto-join event bus
|
|
4
|
+
*
|
|
5
|
+
* Usage: ukimi [kimi args...]
|
|
6
|
+
*
|
|
7
|
+
* Differences vs uclaude:
|
|
8
|
+
* - kimi has no --append-system-prompt style flag, so the default
|
|
9
|
+
* bootstrap is delivered via post-launch PTY injection (same model as
|
|
10
|
+
* ucodex's UFOO_STARTUP_BOOTSTRAP_TEXT path).
|
|
11
|
+
* - Session resume uses kimi's own session ids via `--session <id>`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const AgentLauncher = require("../src/agents/launch/launcher");
|
|
15
|
+
const { resolveDefaultManualBootstrap } = require("../src/agents/prompts/defaultBootstrap");
|
|
16
|
+
|
|
17
|
+
function extractUfooParamsFromArgs(args = []) {
|
|
18
|
+
const nextArgs = [];
|
|
19
|
+
let nickname = "";
|
|
20
|
+
let role = "";
|
|
21
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
22
|
+
const arg = String(args[i] || "");
|
|
23
|
+
if (arg === "--nickname") {
|
|
24
|
+
if (i + 1 < args.length) {
|
|
25
|
+
nickname = String(args[i + 1]).trim();
|
|
26
|
+
i += 1;
|
|
27
|
+
}
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (arg.startsWith("--nickname=")) {
|
|
31
|
+
nickname = arg.slice("--nickname=".length).trim();
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (arg === "--role") {
|
|
35
|
+
if (i + 1 < args.length) {
|
|
36
|
+
role = String(args[i + 1]).trim();
|
|
37
|
+
i += 1;
|
|
38
|
+
}
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (arg.startsWith("--role=")) {
|
|
42
|
+
role = arg.slice("--role=".length).trim();
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
nextArgs.push(args[i]);
|
|
46
|
+
}
|
|
47
|
+
return { args: nextArgs, nickname, role };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { args: cleanArgs, nickname, role } = extractUfooParamsFromArgs(process.argv.slice(2));
|
|
51
|
+
if (nickname) {
|
|
52
|
+
process.env.UFOO_NICKNAME = nickname;
|
|
53
|
+
}
|
|
54
|
+
if (role) {
|
|
55
|
+
process.env.UFOO_PROMPT_PROFILE = role;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const launcher = new AgentLauncher("kimi", "kimi");
|
|
59
|
+
const resolved = resolveDefaultManualBootstrap({
|
|
60
|
+
projectRoot: process.cwd(),
|
|
61
|
+
agentType: "kimi",
|
|
62
|
+
args: cleanArgs,
|
|
63
|
+
env: process.env,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
for (const [key, value] of Object.entries(resolved.env || {})) {
|
|
67
|
+
process.env[key] = String(value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
launcher.launch(resolved.args);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "u-foo",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.10",
|
|
4
4
|
"description": "Multi-Agent Workspace Protocol. Just add u. claude → uclaude, codex → ucodex.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"homepage": "https://ufoo.dev",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"uclaude": "bin/uclaude.js",
|
|
21
21
|
"ucodex": "bin/ucodex.js",
|
|
22
22
|
"uagy": "bin/uagy.js",
|
|
23
|
+
"ukimi": "bin/ukimi.js",
|
|
23
24
|
"ucode": "bin/ucode.js"
|
|
24
25
|
},
|
|
25
26
|
"files": [
|
|
@@ -53,6 +53,13 @@ const INPUT_PATTERNS = {
|
|
|
53
53
|
/Yes, and run (?:in|without) sandbox/i, // Terminal command approval (sandbox toggle)
|
|
54
54
|
/\bn\b\s*-\s*Don't run/i, // y/n/edit confirmation
|
|
55
55
|
],
|
|
56
|
+
// kimi (Kimi Code CLI) ink-style approval menus (verified against 0.27.0).
|
|
57
|
+
kimi: [
|
|
58
|
+
/Run this command\?/, // Bash command approval question
|
|
59
|
+
/Approve once[\s\S]*Approve for this session/, // Approval option list
|
|
60
|
+
/Reject with feedback/, // Approval option (reject path)
|
|
61
|
+
/↑\/↓ select · .*choose · ↵ confirm/, // Interactive menu navigation bar (option count varies)
|
|
62
|
+
],
|
|
56
63
|
};
|
|
57
64
|
|
|
58
65
|
// Agent-specific FATAL patterns that immediately move the agent into BLOCKED
|
|
@@ -209,8 +209,9 @@ function computeInjectedSubmitDelayMs(agentType, text) {
|
|
|
209
209
|
const normalizedAgent = String(agentType || "").trim().toLowerCase();
|
|
210
210
|
const input = typeof text === "string" ? text : "";
|
|
211
211
|
// Agy uses an ink-style TUI like claude-code, so it needs a similar grace
|
|
212
|
-
// window before the input handler picks up injected text.
|
|
213
|
-
|
|
212
|
+
// window before the input handler picks up injected text. Kimi Code is an
|
|
213
|
+
// Ink TUI as well and gets the same treatment.
|
|
214
|
+
const isInkStyle = normalizedAgent === "claude-code" || normalizedAgent === "agy" || normalizedAgent === "kimi";
|
|
214
215
|
let delayMs = isInkStyle ? 350 : 200;
|
|
215
216
|
if (input.includes("\n")) {
|
|
216
217
|
delayMs += isInkStyle ? 250 : 120;
|
|
@@ -244,10 +245,10 @@ async function injectPtyCommand(wrapper, agentType, commandText, source = "injec
|
|
|
244
245
|
const normalizedAgentType = String(agentType || "").trim().toLowerCase();
|
|
245
246
|
const submitDelayMs = computeInjectedSubmitDelayMs(agentType, text);
|
|
246
247
|
wrapper.write(text);
|
|
247
|
-
// claude-code and
|
|
248
|
-
// submit. codex needs the Esc-prefix trick to flush its multi-byte input
|
|
248
|
+
// claude-code, agy and kimi all run ink-style TUIs that accept a bare CR
|
|
249
|
+
// to submit. codex needs the Esc-prefix trick to flush its multi-byte input
|
|
249
250
|
// handler before the CR.
|
|
250
|
-
const isInkStyle = normalizedAgentType === "claude-code" || normalizedAgentType === "agy";
|
|
251
|
+
const isInkStyle = normalizedAgentType === "claude-code" || normalizedAgentType === "agy" || normalizedAgentType === "kimi";
|
|
251
252
|
if (isInkStyle) {
|
|
252
253
|
await sleep(submitDelayMs);
|
|
253
254
|
wrapper.write("\r");
|
|
@@ -108,6 +108,25 @@ class ReadyDetector {
|
|
|
108
108
|
return false;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Detect kimi (Kimi Code CLI) ready markers.
|
|
113
|
+
*
|
|
114
|
+
* kimi renders an ink-style TUI (verified against 0.27.0 via PTY capture):
|
|
115
|
+
* - A status footer "... @: mention files" that only appears once the
|
|
116
|
+
* prompt box is mounted (most reliable signal — post-boot only).
|
|
117
|
+
* - The welcome banner "Welcome to Kimi Code!" as fallback, in case the
|
|
118
|
+
* statusline render changes (e.g. narrow terminal).
|
|
119
|
+
*/
|
|
120
|
+
_detectKimiReady(text) {
|
|
121
|
+
if (text.includes("@: mention files")) {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
if (text.includes("Welcome to Kimi Code!")) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
111
130
|
/**
|
|
112
131
|
* 检测ufoo-code/ucode的ready标记
|
|
113
132
|
*/
|
|
@@ -158,6 +177,8 @@ class ReadyDetector {
|
|
|
158
177
|
isReady = this._detectCodexReady(this.buffer);
|
|
159
178
|
} else if (this.agentType === "agy") {
|
|
160
179
|
isReady = this._detectAgyReady(this.buffer);
|
|
180
|
+
} else if (this.agentType === "kimi") {
|
|
181
|
+
isReady = this._detectKimiReady(this.buffer);
|
|
161
182
|
} else if (this.agentType === "ufoo" || this.agentType === "ucode" || this.agentType === "ufoo-code") {
|
|
162
183
|
isReady = this._detectUfooCodeReady(this.buffer);
|
|
163
184
|
}
|
|
@@ -58,7 +58,9 @@ function buildDefaultStartupBootstrapPrompt({ agentType = "", projectRoot = "" }
|
|
|
58
58
|
? "Codex"
|
|
59
59
|
: (normalizedAgent === "ufoo-code"
|
|
60
60
|
? "ucode"
|
|
61
|
-
: (normalizedAgent === "agy"
|
|
61
|
+
: (normalizedAgent === "agy"
|
|
62
|
+
? "Agy"
|
|
63
|
+
: (normalizedAgent === "kimi" ? "Kimi" : "agent"))));
|
|
62
64
|
|
|
63
65
|
const segments = [
|
|
64
66
|
`Session bootstrap for ${displayAgent}.`,
|
|
@@ -253,11 +255,11 @@ function resolveDefaultManualBootstrap({
|
|
|
253
255
|
const normalizedAgent = asTrimmedString(agentType).toLowerCase();
|
|
254
256
|
const currentEnv = env && typeof env === "object" ? env : {};
|
|
255
257
|
const currentArgs = Array.isArray(args) ? args.slice() : [];
|
|
256
|
-
const
|
|
258
|
+
const hasStartupBootstrap = (normalizedAgent === "codex" || normalizedAgent === "kimi")
|
|
257
259
|
&& Boolean(currentEnv.UFOO_STARTUP_BOOTSTRAP_TEXT);
|
|
258
260
|
if (
|
|
259
261
|
currentEnv.UFOO_SKIP_DEFAULT_BOOTSTRAP === "1"
|
|
260
|
-
||
|
|
262
|
+
|| hasStartupBootstrap
|
|
261
263
|
|| hasMetaCommandArgs(currentArgs)
|
|
262
264
|
) {
|
|
263
265
|
return { args: currentArgs, env: {}, mode: "skip" };
|
|
@@ -328,6 +330,21 @@ function resolveDefaultManualBootstrap({
|
|
|
328
330
|
};
|
|
329
331
|
}
|
|
330
332
|
|
|
333
|
+
if (normalizedAgent === "kimi") {
|
|
334
|
+
// kimi has no --append-system-prompt or positional initial-prompt flag,
|
|
335
|
+
// so the bootstrap always goes out via post-launch PTY injection (the
|
|
336
|
+
// launcher reads UFOO_STARTUP_BOOTSTRAP_TEXT, same as the codex path).
|
|
337
|
+
const promptText = buildDefaultStartupBootstrapPrompt({ agentType: normalizedAgent, projectRoot });
|
|
338
|
+
return {
|
|
339
|
+
args: currentArgs,
|
|
340
|
+
env: {
|
|
341
|
+
UFOO_STARTUP_BOOTSTRAP_TEXT: promptText,
|
|
342
|
+
},
|
|
343
|
+
mode: "post-launch-inject",
|
|
344
|
+
promptText,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
331
348
|
if (normalizedAgent === "agy") {
|
|
332
349
|
const promptText = buildDefaultStartupBootstrapPrompt({ agentType: normalizedAgent, projectRoot });
|
|
333
350
|
// If the user passed -i / --prompt-interactive, fold the bootstrap into
|
|
@@ -104,3 +104,10 @@ module.exports = {
|
|
|
104
104
|
toLegacyResolvedAuth,
|
|
105
105
|
buildUpstreamAuthFromCredential,
|
|
106
106
|
};
|
|
107
|
+
|
|
108
|
+
// Attached lazily after the shared helpers so the circular require from
|
|
109
|
+
// ./kimi (which pulls buildCredentialDescriptor from here) always resolves.
|
|
110
|
+
Object.defineProperty(module.exports, "kimi", {
|
|
111
|
+
enumerable: true,
|
|
112
|
+
get: () => require("./kimi"),
|
|
113
|
+
});
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const os = require("os");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const {
|
|
7
|
+
buildCredentialDescriptor,
|
|
8
|
+
} = require("./index");
|
|
9
|
+
|
|
10
|
+
const KIMI_OAUTH_TOKEN_URL = "https://auth.kimi.com/api/oauth/token";
|
|
11
|
+
const KIMI_OAUTH_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
|
|
12
|
+
const DEFAULT_REFRESH_WINDOW_MS = 300 * 1000;
|
|
13
|
+
const DEFAULT_LOCK_TIMEOUT_MS = 3000;
|
|
14
|
+
const DEFAULT_LOCK_RETRY_MS = 25;
|
|
15
|
+
const DEFAULT_STALE_LOCK_MS = 30 * 1000;
|
|
16
|
+
|
|
17
|
+
function sleep(ms) {
|
|
18
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function defaultKimiHome(env = process.env) {
|
|
22
|
+
const configured = String(env.KIMI_CODE_HOME || "").trim();
|
|
23
|
+
if (configured) return configured;
|
|
24
|
+
return path.join(os.homedir(), ".kimi-code");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveKimiCredentialPaths(options = {}) {
|
|
28
|
+
const env = options.env || process.env;
|
|
29
|
+
const home = String(options.home || options.configDir || defaultKimiHome(env)).trim() || defaultKimiHome(env);
|
|
30
|
+
const explicitCredentialPath = String(options.credentialPath || "").trim();
|
|
31
|
+
const credentialPath = explicitCredentialPath || path.join(home, "credentials", "kimi-code.json");
|
|
32
|
+
return {
|
|
33
|
+
home,
|
|
34
|
+
credentialPath,
|
|
35
|
+
lockPath: `${credentialPath}.lock`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function firstString(...values) {
|
|
40
|
+
for (const value of values) {
|
|
41
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
42
|
+
}
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// kimi CLI stores expires_at as Unix epoch seconds (e.g. 1784486687); accept
|
|
47
|
+
// epoch millis and ISO strings as well so hand-rolled fixtures keep working.
|
|
48
|
+
function parseKimiExpiresAtMs(value) {
|
|
49
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
50
|
+
return value > 1e12 ? Math.floor(value) : Math.floor(value * 1000);
|
|
51
|
+
}
|
|
52
|
+
const text = String(value || "").trim();
|
|
53
|
+
if (!text) return NaN;
|
|
54
|
+
if (/^\d+(?:\.\d+)?$/.test(text)) {
|
|
55
|
+
const num = Number(text);
|
|
56
|
+
return num > 1e12 ? Math.floor(num) : Math.floor(num * 1000);
|
|
57
|
+
}
|
|
58
|
+
return Date.parse(text);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseKimiCredentialFile(raw = {}) {
|
|
62
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
63
|
+
const err = new Error("Kimi credential payload must be a JSON object");
|
|
64
|
+
err.code = "KIMI_AUTH_INVALID";
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const accessToken = firstString(raw.access_token, raw.accessToken);
|
|
69
|
+
const refreshToken = firstString(raw.refresh_token, raw.refreshToken);
|
|
70
|
+
if (!accessToken && !refreshToken) {
|
|
71
|
+
const err = new Error("Unsupported Kimi credential schema");
|
|
72
|
+
err.code = "KIMI_AUTH_SCHEMA_UNSUPPORTED";
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const expiresAtMs = parseKimiExpiresAtMs(
|
|
77
|
+
raw.expires_at !== undefined ? raw.expires_at : raw.expiresAt
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
schemaVersion: "kimi-code-credentials-v1",
|
|
82
|
+
raw,
|
|
83
|
+
accessToken,
|
|
84
|
+
refreshToken,
|
|
85
|
+
tokenType: firstString(raw.token_type, raw.tokenType, "Bearer"),
|
|
86
|
+
scope: firstString(raw.scope),
|
|
87
|
+
expiresAt: Number.isFinite(expiresAtMs) ? new Date(expiresAtMs).toISOString() : "",
|
|
88
|
+
expiresAtMs,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function withLockFile(lockPath, options = {}, fn) {
|
|
93
|
+
const fsModule = options.fsModule || fs;
|
|
94
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_LOCK_TIMEOUT_MS;
|
|
95
|
+
const retryMs = Number.isFinite(options.retryMs) ? options.retryMs : DEFAULT_LOCK_RETRY_MS;
|
|
96
|
+
const staleMs = Number.isFinite(options.staleMs) ? options.staleMs : DEFAULT_STALE_LOCK_MS;
|
|
97
|
+
const sleepFn = typeof options.sleep === "function" ? options.sleep : sleep;
|
|
98
|
+
const startedAt = Date.now();
|
|
99
|
+
|
|
100
|
+
fsModule.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
101
|
+
|
|
102
|
+
while ((Date.now() - startedAt) <= timeoutMs) {
|
|
103
|
+
let fd = null;
|
|
104
|
+
try {
|
|
105
|
+
fd = fsModule.openSync(lockPath, "wx", 0o600);
|
|
106
|
+
try {
|
|
107
|
+
return await fn();
|
|
108
|
+
} finally {
|
|
109
|
+
try { fsModule.closeSync(fd); } catch {}
|
|
110
|
+
try { fsModule.unlinkSync(lockPath); } catch {}
|
|
111
|
+
}
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (!err || err.code !== "EEXIST") throw err;
|
|
114
|
+
try {
|
|
115
|
+
const stat = fsModule.statSync(lockPath);
|
|
116
|
+
if ((Date.now() - stat.mtimeMs) > staleMs) {
|
|
117
|
+
fsModule.unlinkSync(lockPath);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
} catch {}
|
|
121
|
+
// eslint-disable-next-line no-await-in-loop
|
|
122
|
+
await sleepFn(retryMs);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const err = new Error(`Timed out waiting for Kimi OAuth lock: ${lockPath}`);
|
|
127
|
+
err.code = "KIMI_AUTH_LOCK_TIMEOUT";
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
class KimiUpstreamCredentialResolver {
|
|
132
|
+
constructor(options = {}) {
|
|
133
|
+
this.fs = options.fsModule || fs;
|
|
134
|
+
this.env = options.env || process.env;
|
|
135
|
+
this.fetchImpl = options.fetchImpl || global.fetch;
|
|
136
|
+
this.now = typeof options.now === "function" ? options.now : () => Date.now();
|
|
137
|
+
this.paths = resolveKimiCredentialPaths(options);
|
|
138
|
+
this.refreshWindowMs = Number.isFinite(options.refreshWindowMs) ? options.refreshWindowMs : DEFAULT_REFRESH_WINDOW_MS;
|
|
139
|
+
this.autoRefresh = options.autoRefresh !== false;
|
|
140
|
+
this.refreshRetries = Number.isInteger(options.refreshRetries) && options.refreshRetries > 0
|
|
141
|
+
? options.refreshRetries
|
|
142
|
+
: 2;
|
|
143
|
+
this.sleep = typeof options.sleep === "function" ? options.sleep : sleep;
|
|
144
|
+
this.lockTimeoutMs = Number.isFinite(options.lockTimeoutMs) ? options.lockTimeoutMs : DEFAULT_LOCK_TIMEOUT_MS;
|
|
145
|
+
this.lockRetryMs = Number.isFinite(options.lockRetryMs) ? options.lockRetryMs : DEFAULT_LOCK_RETRY_MS;
|
|
146
|
+
this.lockStaleMs = Number.isFinite(options.lockStaleMs) ? options.lockStaleMs : DEFAULT_STALE_LOCK_MS;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
resolvePaths() {
|
|
150
|
+
return { ...this.paths };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
readCredentialFile() {
|
|
154
|
+
const raw = JSON.parse(this.fs.readFileSync(this.paths.credentialPath, "utf8"));
|
|
155
|
+
return parseKimiCredentialFile(raw);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
writeCredentialFile(raw) {
|
|
159
|
+
const text = `${JSON.stringify(raw, null, 2)}\n`;
|
|
160
|
+
const tmpPath = `${this.paths.credentialPath}.tmp-${process.pid}-${Date.now()}`;
|
|
161
|
+
this.fs.mkdirSync(path.dirname(this.paths.credentialPath), { recursive: true, mode: 0o700 });
|
|
162
|
+
this.fs.writeFileSync(tmpPath, text, { encoding: "utf8", mode: 0o600 });
|
|
163
|
+
this.fs.renameSync(tmpPath, this.paths.credentialPath);
|
|
164
|
+
try { this.fs.chmodSync(this.paths.credentialPath, 0o600); } catch {}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async refreshTokens(refreshToken) {
|
|
168
|
+
if (typeof this.fetchImpl !== "function") {
|
|
169
|
+
const err = new Error("fetch is unavailable for Kimi token refresh");
|
|
170
|
+
err.code = "KIMI_AUTH_REFRESH_UNAVAILABLE";
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let lastErr = null;
|
|
175
|
+
for (let attempt = 0; attempt < this.refreshRetries; attempt += 1) {
|
|
176
|
+
try {
|
|
177
|
+
const body = new URLSearchParams({
|
|
178
|
+
client_id: KIMI_OAUTH_CLIENT_ID,
|
|
179
|
+
grant_type: "refresh_token",
|
|
180
|
+
refresh_token: refreshToken,
|
|
181
|
+
});
|
|
182
|
+
const response = await this.fetchImpl(KIMI_OAUTH_TOKEN_URL, {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: {
|
|
185
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
186
|
+
accept: "application/json",
|
|
187
|
+
},
|
|
188
|
+
body: body.toString(),
|
|
189
|
+
});
|
|
190
|
+
const text = await response.text();
|
|
191
|
+
if (!response.ok) {
|
|
192
|
+
const err = new Error(`Kimi token refresh failed (${response.status}): ${text.slice(0, 500)}`);
|
|
193
|
+
err.code = "KIMI_AUTH_REFRESH_FAILED";
|
|
194
|
+
err.status = response.status;
|
|
195
|
+
throw err;
|
|
196
|
+
}
|
|
197
|
+
const payload = JSON.parse(text);
|
|
198
|
+
if (!payload || typeof payload !== "object" || !payload.access_token) {
|
|
199
|
+
const err = new Error("Kimi token refresh response did not include access_token");
|
|
200
|
+
err.code = "KIMI_AUTH_REFRESH_SCHEMA_UNSUPPORTED";
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
const expiresIn = Number(payload.expires_in);
|
|
204
|
+
const expiresAtMs = Number.isFinite(expiresIn) && expiresIn > 0
|
|
205
|
+
? this.now() + Math.floor(expiresIn * 1000)
|
|
206
|
+
: NaN;
|
|
207
|
+
return {
|
|
208
|
+
accessToken: firstString(payload.access_token),
|
|
209
|
+
refreshToken: firstString(payload.refresh_token, refreshToken),
|
|
210
|
+
tokenType: firstString(payload.token_type, "Bearer"),
|
|
211
|
+
scope: firstString(payload.scope),
|
|
212
|
+
expiresAt: Number.isFinite(expiresAtMs) ? new Date(expiresAtMs).toISOString() : "",
|
|
213
|
+
expiresAtMs,
|
|
214
|
+
};
|
|
215
|
+
} catch (err) {
|
|
216
|
+
lastErr = err;
|
|
217
|
+
if (err && err.code === "KIMI_AUTH_REFRESH_SCHEMA_UNSUPPORTED") {
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const err = new Error(lastErr && lastErr.message ? lastErr.message : "Kimi token refresh failed");
|
|
224
|
+
err.code = lastErr && lastErr.code ? lastErr.code : "KIMI_AUTH_REFRESH_FAILED";
|
|
225
|
+
err.cause = lastErr;
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async refreshCredentialRecord(record) {
|
|
230
|
+
if (!record || !record.refreshToken) {
|
|
231
|
+
const err = new Error("Kimi OAuth credential is expired and has no refresh token");
|
|
232
|
+
err.code = "KIMI_AUTH_REFRESH_UNAVAILABLE";
|
|
233
|
+
throw err;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return withLockFile(this.paths.lockPath, {
|
|
237
|
+
fsModule: this.fs,
|
|
238
|
+
timeoutMs: this.lockTimeoutMs,
|
|
239
|
+
retryMs: this.lockRetryMs,
|
|
240
|
+
staleMs: this.lockStaleMs,
|
|
241
|
+
sleep: this.sleep,
|
|
242
|
+
}, async () => {
|
|
243
|
+
const currentRecord = this.readCredentialFile();
|
|
244
|
+
const currentDescriptor = this.buildResolvedCredential(currentRecord);
|
|
245
|
+
if (currentDescriptor.state === "fresh") {
|
|
246
|
+
return currentRecord;
|
|
247
|
+
}
|
|
248
|
+
if (!currentRecord.refreshToken) {
|
|
249
|
+
const err = new Error("Kimi OAuth credential is expired and has no refresh token");
|
|
250
|
+
err.code = "KIMI_AUTH_REFRESH_UNAVAILABLE";
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const refreshed = await this.refreshTokens(currentRecord.refreshToken);
|
|
255
|
+
const nextRaw = currentRecord.raw && typeof currentRecord.raw === "object" && !Array.isArray(currentRecord.raw)
|
|
256
|
+
? { ...currentRecord.raw }
|
|
257
|
+
: {};
|
|
258
|
+
nextRaw.access_token = refreshed.accessToken;
|
|
259
|
+
nextRaw.refresh_token = refreshed.refreshToken || currentRecord.refreshToken || "";
|
|
260
|
+
if (refreshed.tokenType) nextRaw.token_type = refreshed.tokenType;
|
|
261
|
+
if (refreshed.scope) nextRaw.scope = refreshed.scope;
|
|
262
|
+
if (Number.isFinite(refreshed.expiresAtMs)) {
|
|
263
|
+
nextRaw.expires_at = Math.floor(refreshed.expiresAtMs / 1000);
|
|
264
|
+
}
|
|
265
|
+
this.writeCredentialFile(nextRaw);
|
|
266
|
+
return this.readCredentialFile();
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
buildResolvedCredential(record) {
|
|
271
|
+
return buildCredentialDescriptor({
|
|
272
|
+
provider: "kimi",
|
|
273
|
+
credentialKind: "oauth",
|
|
274
|
+
source: "credential-file",
|
|
275
|
+
accessToken: record.accessToken,
|
|
276
|
+
refreshToken: record.refreshToken,
|
|
277
|
+
tokenType: record.tokenType || "Bearer",
|
|
278
|
+
expiresAt: record.expiresAt,
|
|
279
|
+
expiresAtMs: record.expiresAtMs,
|
|
280
|
+
refreshable: Boolean(record.refreshToken),
|
|
281
|
+
credentialPath: this.paths.credentialPath,
|
|
282
|
+
schemaVersion: record.schemaVersion,
|
|
283
|
+
nowMs: this.now(),
|
|
284
|
+
refreshWindowMs: this.refreshWindowMs,
|
|
285
|
+
metadata: {
|
|
286
|
+
scope: record.scope,
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async resolveCredentials() {
|
|
292
|
+
let record;
|
|
293
|
+
try {
|
|
294
|
+
record = this.readCredentialFile();
|
|
295
|
+
} catch (err) {
|
|
296
|
+
if (err && err.code === "ENOENT") {
|
|
297
|
+
const missing = new Error("Kimi credential file not found; run `kimi` once to sign in");
|
|
298
|
+
missing.code = "KIMI_AUTH_UNAVAILABLE";
|
|
299
|
+
throw missing;
|
|
300
|
+
}
|
|
301
|
+
throw err;
|
|
302
|
+
}
|
|
303
|
+
const descriptor = this.buildResolvedCredential(record);
|
|
304
|
+
if (
|
|
305
|
+
this.autoRefresh
|
|
306
|
+
&& descriptor.refreshable
|
|
307
|
+
&& (descriptor.state === "expired" || descriptor.state === "near_expiry")
|
|
308
|
+
) {
|
|
309
|
+
const refreshedRecord = await this.refreshCredentialRecord(record);
|
|
310
|
+
return this.buildResolvedCredential(refreshedRecord);
|
|
311
|
+
}
|
|
312
|
+
return descriptor;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function resolveKimiUpstreamCredentials(options = {}) {
|
|
317
|
+
const resolver = new KimiUpstreamCredentialResolver(options);
|
|
318
|
+
return resolver.resolveCredentials();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Synchronous, network-free read used by resolveRuntimeConfig: returns the
|
|
322
|
+
// credential descriptor (fresh or not) or null when the file is unusable.
|
|
323
|
+
function readKimiAccessToken(options = {}) {
|
|
324
|
+
try {
|
|
325
|
+
const resolver = new KimiUpstreamCredentialResolver({ ...options, autoRefresh: false });
|
|
326
|
+
const record = resolver.readCredentialFile();
|
|
327
|
+
return resolver.buildResolvedCredential(record);
|
|
328
|
+
} catch {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
module.exports = {
|
|
334
|
+
KimiUpstreamCredentialResolver,
|
|
335
|
+
resolveKimiUpstreamCredentials,
|
|
336
|
+
resolveKimiCredentialPaths,
|
|
337
|
+
parseKimiCredentialFile,
|
|
338
|
+
parseKimiExpiresAtMs,
|
|
339
|
+
readKimiAccessToken,
|
|
340
|
+
KIMI_OAUTH_TOKEN_URL,
|
|
341
|
+
KIMI_OAUTH_CLIENT_ID,
|
|
342
|
+
};
|