mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Merge branch 'main' into feat/mcp-command
This commit is contained in:
+11
-11
@@ -9,7 +9,7 @@
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
|
||||
[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli) · [English](https://github.com/modelstudioai/cli/blob/main/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key)
|
||||
[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [English](https://github.com/modelstudioai/cli/blob/main/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cli?source_channel=key_github&)
|
||||
|
||||
---
|
||||
|
||||
@@ -100,13 +100,13 @@ bl app list
|
||||
bl usage free --model qwen3-max
|
||||
```
|
||||
|
||||
> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli)
|
||||
> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
|
||||
|
||||
## 认证方式
|
||||
|
||||
### DashScope API Key
|
||||
|
||||
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key) 获取。
|
||||
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cli?source_channel=key_github&) 获取。
|
||||
|
||||
```bash
|
||||
# 方式一:环境变量
|
||||
@@ -158,14 +158,14 @@ bl update
|
||||
|
||||
## 相关链接
|
||||
|
||||
| 资源 | 地址 |
|
||||
| :---------------------- | :-------------------------------------------------------------- |
|
||||
| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli |
|
||||
| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ |
|
||||
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
|
||||
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/ |
|
||||
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key |
|
||||
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
| 资源 | 地址 |
|
||||
| :---------------------- | :---------------------------------------------------------------- |
|
||||
| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
|
||||
| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ |
|
||||
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
|
||||
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/ |
|
||||
| 获取 API Key | https://bailian.console.aliyun.com/cli?source_channel=key_github& |
|
||||
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## 更新日志
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.3",
|
||||
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
|
||||
"keywords": [
|
||||
"agent",
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import http from "node:http";
|
||||
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
getConfigPath,
|
||||
readConfigFile,
|
||||
writeConfigFile,
|
||||
} from "bailian-cli-core";
|
||||
|
||||
const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
const MAX_AUTH_CALLBACK_BODY = 65536;
|
||||
|
||||
const DEFAULT_CONSOLE_ORIGIN = "https://bailian.console.aliyun.com";
|
||||
|
||||
export function resolveConsoleOrigin(): string {
|
||||
return process.env.BAILIAN_CONSOLE_ORIGIN || DEFAULT_CONSOLE_ORIGIN;
|
||||
}
|
||||
|
||||
function readBodyBounded(req: http.IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_AUTH_CALLBACK_BODY) {
|
||||
reject(new Error("payload too large"));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function requestContentType(req: http.IncomingMessage): string {
|
||||
const h = req.headers["content-type"];
|
||||
if (Array.isArray(h)) return h[0] ?? "";
|
||||
return typeof h === "string" ? h : "";
|
||||
}
|
||||
|
||||
function multipartBoundary(contentType: string): string | null {
|
||||
const parts = contentType.split(";");
|
||||
for (const p of parts) {
|
||||
const s = p.trim();
|
||||
if (!s.toLowerCase().startsWith("boundary=")) continue;
|
||||
let b = s.slice("boundary=".length).trim();
|
||||
if ((b.startsWith('"') && b.endsWith('"')) || (b.startsWith("'") && b.endsWith("'"))) {
|
||||
b = b.slice(1, -1);
|
||||
}
|
||||
return b.length > 0 ? b : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseAccessTokenFromMultipart(raw: string, boundaryValue: string): string | null {
|
||||
const delim = `--${boundaryValue}`;
|
||||
const segments = raw.split(delim);
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
const part = segments[i]!;
|
||||
if (!/name\s*=\s*["'](?:access_token|accessToken)["']/i.test(part)) continue;
|
||||
const sep = part.match(/\r\n\r\n|\n\n/);
|
||||
if (!sep || sep.index === undefined) continue;
|
||||
let value = part.slice(sep.index + sep[0].length);
|
||||
value = value
|
||||
.replace(/(?:\r\n)+$/g, "")
|
||||
.replace(/\n+$/g, "")
|
||||
.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tokenFieldFromRecord(o: Record<string, unknown>): string | null {
|
||||
for (const k of ["access_token", "accessToken"]) {
|
||||
const v = o[k];
|
||||
if (typeof v === "string" && v.trim()) return v.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function apiKeyFieldFromRecord(o: Record<string, unknown>): string | null {
|
||||
for (const k of ["api_key", "apiKey"]) {
|
||||
const v = o[k];
|
||||
if (typeof v === "string" && v.trim()) return v.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseAccessTokenFromJsonText(text: string): string | null {
|
||||
let t = text.trim();
|
||||
if (t.charCodeAt(0) === 0xfeff) t = t.slice(1);
|
||||
if (!t) return null;
|
||||
let j: unknown;
|
||||
try {
|
||||
j = JSON.parse(t);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!j || typeof j !== "object" || Array.isArray(j)) return null;
|
||||
const o = j as Record<string, unknown>;
|
||||
const direct = tokenFieldFromRecord(o);
|
||||
if (direct) return direct;
|
||||
const data = o.data;
|
||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||
const inner = tokenFieldFromRecord(data as Record<string, unknown>);
|
||||
if (inner) return inner;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseAccessTokenFromRawBody(raw: string, contentType: string): string | null {
|
||||
const ct = contentType.toLowerCase();
|
||||
if (!raw.trim()) return null;
|
||||
|
||||
if (ct.includes("multipart/form-data")) {
|
||||
const b = multipartBoundary(contentType);
|
||||
if (b) {
|
||||
const tok = parseAccessTokenFromMultipart(raw, b);
|
||||
if (tok) return tok;
|
||||
}
|
||||
}
|
||||
|
||||
if (ct.includes("application/json") || ct.includes("text/json")) {
|
||||
const t = parseAccessTokenFromJsonText(raw);
|
||||
if (t) return t;
|
||||
}
|
||||
|
||||
if (ct.includes("application/x-www-form-urlencoded")) {
|
||||
try {
|
||||
const params = new URLSearchParams(raw.trim());
|
||||
const v = params.get("access_token") ?? params.get("accessToken");
|
||||
if (v?.trim()) return v.trim();
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
|
||||
// Fallbacks when Content-Type is missing or nonstandard (many fetch() callers omit it).
|
||||
const jsonTok = parseAccessTokenFromJsonText(raw);
|
||||
if (jsonTok) return jsonTok;
|
||||
try {
|
||||
const params = new URLSearchParams(raw.trim());
|
||||
const v = params.get("access_token") ?? params.get("accessToken");
|
||||
if (v?.trim()) return v.trim();
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
const b = multipartBoundary(contentType);
|
||||
if (b) {
|
||||
const tok = parseAccessTokenFromMultipart(raw, b);
|
||||
if (tok) return tok;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseApiKeyFromJsonText(text: string): string | null {
|
||||
let t = text.trim();
|
||||
if (t.charCodeAt(0) === 0xfeff) t = t.slice(1);
|
||||
if (!t) return null;
|
||||
let j: unknown;
|
||||
try {
|
||||
j = JSON.parse(t);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!j || typeof j !== "object" || Array.isArray(j)) return null;
|
||||
const o = j as Record<string, unknown>;
|
||||
const direct = apiKeyFieldFromRecord(o);
|
||||
if (direct) return direct;
|
||||
const data = o.data;
|
||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||
const inner = apiKeyFieldFromRecord(data as Record<string, unknown>);
|
||||
if (inner) return inner;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseApiKeyFromRawBody(raw: string, contentType: string): string | null {
|
||||
const ct = contentType.toLowerCase();
|
||||
if (!raw.trim()) return null;
|
||||
|
||||
if (ct.includes("application/json") || ct.includes("text/json")) {
|
||||
const t = parseApiKeyFromJsonText(raw);
|
||||
if (t) return t;
|
||||
}
|
||||
|
||||
if (ct.includes("application/x-www-form-urlencoded")) {
|
||||
try {
|
||||
const params = new URLSearchParams(raw.trim());
|
||||
const v = params.get("api_key") ?? params.get("apiKey");
|
||||
if (v?.trim()) return v.trim();
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
|
||||
const jsonTok = parseApiKeyFromJsonText(raw);
|
||||
if (jsonTok) return jsonTok;
|
||||
try {
|
||||
const params = new URLSearchParams(raw.trim());
|
||||
const v = params.get("api_key") ?? params.get("apiKey");
|
||||
if (v?.trim()) return v.trim();
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface CallbackCredentials {
|
||||
accessToken: string | null;
|
||||
apiKey: string | null;
|
||||
}
|
||||
|
||||
async function extractCredentialsFromRequest(
|
||||
req: http.IncomingMessage,
|
||||
): Promise<CallbackCredentials> {
|
||||
const u = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
const accessTokenFromQuery =
|
||||
u.searchParams.get("access_token") ?? u.searchParams.get("accessToken");
|
||||
const apiKeyFromQuery = u.searchParams.get("api_key") ?? u.searchParams.get("apiKey");
|
||||
|
||||
const m = req.method ?? "GET";
|
||||
if (m !== "POST" && m !== "PUT" && m !== "PATCH") {
|
||||
return {
|
||||
accessToken: accessTokenFromQuery?.trim() || null,
|
||||
apiKey: apiKeyFromQuery?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
const contentType = requestContentType(req);
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readBodyBounded(req);
|
||||
} catch {
|
||||
return {
|
||||
accessToken: accessTokenFromQuery?.trim() || null,
|
||||
apiKey: apiKeyFromQuery?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
const accessToken = accessTokenFromQuery?.trim() || parseAccessTokenFromRawBody(raw, contentType);
|
||||
const apiKey = apiKeyFromQuery?.trim() || parseApiKeyFromRawBody(raw, contentType);
|
||||
return { accessToken, apiKey };
|
||||
}
|
||||
|
||||
function listenServerOnFreeLocalPort(server: http.Server): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onErr = (e: Error) => reject(e);
|
||||
server.once("error", onErr);
|
||||
server.listen({ port: 0, host: "127.0.0.1", exclusive: true }, () => {
|
||||
server.off("error", onErr);
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === "string") {
|
||||
reject(new Error("Expected TCP socket address"));
|
||||
return;
|
||||
}
|
||||
resolve(addr.port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openInBrowser(url: string): Promise<void> {
|
||||
const platform = process.platform;
|
||||
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
||||
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(cmd, args, { windowsHide: true }, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function runConsoleLogin(
|
||||
consoleOrigin: string,
|
||||
opts?: { needApiKey?: boolean; onApiKey?: (key: string) => Promise<void> },
|
||||
): Promise<void> {
|
||||
const state = randomBytes(16).toString("hex");
|
||||
let callbackError: unknown;
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204, {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const u = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (u.searchParams.get("state") !== state) {
|
||||
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("bad state\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const { accessToken, apiKey } = await extractCredentialsFromRequest(req);
|
||||
|
||||
if (accessToken || apiKey) {
|
||||
try {
|
||||
if (accessToken) {
|
||||
const existing = readConfigFile() as Record<string, unknown>;
|
||||
existing.access_token = accessToken;
|
||||
await writeConfigFile(existing);
|
||||
process.stderr.write(`access_token saved to ${getConfigPath()}\n`);
|
||||
}
|
||||
if (apiKey && opts?.onApiKey) {
|
||||
await opts.onApiKey(apiKey);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
callbackError = err;
|
||||
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("Failed to save credentials\n");
|
||||
server.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
});
|
||||
res.end("OK\n");
|
||||
|
||||
if (accessToken || apiKey) {
|
||||
server.close();
|
||||
}
|
||||
} catch {
|
||||
res.statusCode = 500;
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
let port: number;
|
||||
try {
|
||||
port = await listenServerOnFreeLocalPort(server);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new BailianError(
|
||||
`Could not bind to 127.0.0.1 (no free port or permission denied): ${msg}`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
let loginUrl = `${consoleOrigin}/console-login?notice=127.0.0.1:${port}?state=${encodeURIComponent(state)}`;
|
||||
if (opts?.needApiKey) {
|
||||
loginUrl += "&needapikey=true";
|
||||
}
|
||||
|
||||
try {
|
||||
await openInBrowser(loginUrl);
|
||||
process.stderr.write(
|
||||
"Opened the login page in your default browser. This process keeps the local port open for the console; press Ctrl+C when finished (or wait for idle timeout).\n",
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(
|
||||
`Could not open the default browser (${msg}). Open this URL manually:\n\n`,
|
||||
);
|
||||
process.stdout.write(`${loginUrl}\n`);
|
||||
process.stderr.write(
|
||||
"\nThis process keeps the local port open for the console; press Ctrl+C when finished (or wait for idle timeout).\n",
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let finished = false;
|
||||
const done = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
server.close();
|
||||
}, CONSOLE_LOGIN_TIMEOUT_MS);
|
||||
|
||||
server.once("close", done);
|
||||
server.once("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
if (!finished) {
|
||||
finished = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (callbackError) {
|
||||
throw callbackError;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import http from "node:http";
|
||||
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
@@ -20,206 +16,60 @@ import { printQuickStart } from "../../output/banner.ts";
|
||||
import { emitBare } from "../../output/output.ts";
|
||||
import { promptConfirm } from "../../output/prompt.ts";
|
||||
import { printCurrentCommandHelp } from "../../utils/command-help.ts";
|
||||
import { resolveConsoleOrigin, runConsoleLogin } from "./login-console.ts";
|
||||
|
||||
const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
const MAX_AUTH_CALLBACK_BODY = 65536;
|
||||
const RETRY_DELAY_BASE_MS = 500;
|
||||
|
||||
const DEFAULT_CONSOLE_ORIGIN = "https://bailian.console.aliyun.com";
|
||||
|
||||
function resolveConsoleOrigin(): string {
|
||||
return process.env.BAILIAN_CONSOLE_ORIGIN || DEFAULT_CONSOLE_ORIGIN;
|
||||
}
|
||||
|
||||
function readBodyBounded(req: http.IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_AUTH_CALLBACK_BODY) {
|
||||
reject(new Error("payload too large"));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function requestContentType(req: http.IncomingMessage): string {
|
||||
const h = req.headers["content-type"];
|
||||
if (Array.isArray(h)) return h[0] ?? "";
|
||||
return typeof h === "string" ? h : "";
|
||||
}
|
||||
|
||||
function multipartBoundary(contentType: string): string | null {
|
||||
const parts = contentType.split(";");
|
||||
for (const p of parts) {
|
||||
const s = p.trim();
|
||||
if (!s.toLowerCase().startsWith("boundary=")) continue;
|
||||
let b = s.slice("boundary=".length).trim();
|
||||
if ((b.startsWith('"') && b.endsWith('"')) || (b.startsWith("'") && b.endsWith("'"))) {
|
||||
b = b.slice(1, -1);
|
||||
function canRetry(err: unknown): boolean {
|
||||
if (err instanceof BailianError) {
|
||||
if (err.exitCode === ExitCode.NETWORK || err.exitCode === ExitCode.TIMEOUT) {
|
||||
return true;
|
||||
}
|
||||
return b.length > 0 ? b : null;
|
||||
const status = err.api?.httpStatus;
|
||||
return status === 401 || (status !== undefined && status >= 500);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** multipart/form-data: find part with name=access_token and return its body. */
|
||||
function parseAccessTokenFromMultipart(raw: string, boundaryValue: string): string | null {
|
||||
const delim = `--${boundaryValue}`;
|
||||
const segments = raw.split(delim);
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
const part = segments[i]!;
|
||||
if (!/name\s*=\s*["'](?:access_token|accessToken)["']/i.test(part)) continue;
|
||||
const sep = part.match(/\r\n\r\n|\n\n/);
|
||||
if (!sep || sep.index === undefined) continue;
|
||||
let value = part.slice(sep.index + sep[0].length);
|
||||
value = value
|
||||
.replace(/(?:\r\n)+$/g, "")
|
||||
.replace(/\n+$/g, "")
|
||||
.trim();
|
||||
if (value) return value;
|
||||
if (err instanceof Error) {
|
||||
return (
|
||||
err.name === "AbortError" ||
|
||||
err.name === "TimeoutError" ||
|
||||
err.message.includes("timed out") ||
|
||||
err.message === "fetch failed"
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tokenFieldFromRecord(o: Record<string, unknown>): string | null {
|
||||
for (const k of ["access_token", "accessToken"]) {
|
||||
const v = o[k];
|
||||
if (typeof v === "string" && v.trim()) return v.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseAccessTokenFromJsonText(text: string): string | null {
|
||||
let t = text.trim();
|
||||
if (t.charCodeAt(0) === 0xfeff) t = t.slice(1);
|
||||
if (!t) return null;
|
||||
let j: unknown;
|
||||
try {
|
||||
j = JSON.parse(t);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!j || typeof j !== "object" || Array.isArray(j)) return null;
|
||||
const o = j as Record<string, unknown>;
|
||||
const direct = tokenFieldFromRecord(o);
|
||||
if (direct) return direct;
|
||||
const data = o.data;
|
||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||
const inner = tokenFieldFromRecord(data as Record<string, unknown>);
|
||||
if (inner) return inner;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseAccessTokenFromRawBody(raw: string, contentType: string): string | null {
|
||||
const ct = contentType.toLowerCase();
|
||||
if (!raw.trim()) return null;
|
||||
|
||||
if (ct.includes("multipart/form-data")) {
|
||||
const b = multipartBoundary(contentType);
|
||||
if (b) {
|
||||
const tok = parseAccessTokenFromMultipart(raw, b);
|
||||
if (tok) return tok;
|
||||
}
|
||||
}
|
||||
|
||||
if (ct.includes("application/json") || ct.includes("text/json")) {
|
||||
const t = parseAccessTokenFromJsonText(raw);
|
||||
if (t) return t;
|
||||
}
|
||||
|
||||
if (ct.includes("application/x-www-form-urlencoded")) {
|
||||
try {
|
||||
const params = new URLSearchParams(raw.trim());
|
||||
const v = params.get("access_token") ?? params.get("accessToken");
|
||||
if (v?.trim()) return v.trim();
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
|
||||
// Fallbacks when Content-Type is missing or nonstandard (many fetch() callers omit it).
|
||||
const jsonTok = parseAccessTokenFromJsonText(raw);
|
||||
if (jsonTok) return jsonTok;
|
||||
try {
|
||||
const params = new URLSearchParams(raw.trim());
|
||||
const v = params.get("access_token") ?? params.get("accessToken");
|
||||
if (v?.trim()) return v.trim();
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
const b = multipartBoundary(contentType);
|
||||
if (b) {
|
||||
const tok = parseAccessTokenFromMultipart(raw, b);
|
||||
if (tok) return tok;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function extractAccessTokenFromRequest(req: http.IncomingMessage): Promise<string | null> {
|
||||
const u = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
const fromQuery = u.searchParams.get("access_token") ?? u.searchParams.get("accessToken");
|
||||
if (fromQuery?.trim()) return fromQuery.trim();
|
||||
|
||||
const m = req.method ?? "GET";
|
||||
if (m !== "POST" && m !== "PUT" && m !== "PATCH") return null;
|
||||
|
||||
const contentType = requestContentType(req);
|
||||
try {
|
||||
const raw = await readBodyBounded(req);
|
||||
return parseAccessTokenFromRawBody(raw, contentType);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Binds to an ephemeral port on loopback; the OS only assigns ports that are free at bind time. */
|
||||
function listenServerOnFreeLocalPort(server: http.Server): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onErr = (e: Error) => reject(e);
|
||||
server.once("error", onErr);
|
||||
server.listen({ port: 0, host: "127.0.0.1", exclusive: true }, () => {
|
||||
server.off("error", onErr);
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === "string") {
|
||||
reject(new Error("Expected TCP socket address"));
|
||||
return;
|
||||
}
|
||||
resolve(addr.port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openInBrowser(url: string): Promise<void> {
|
||||
const platform = process.platform;
|
||||
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
||||
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(cmd, args, { windowsHide: true }, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
async function validateKeyAndPersist(config: Config, key: string): Promise<void> {
|
||||
process.stderr.write("Testing key... ");
|
||||
const testConfig = { ...config, apiKey: key };
|
||||
await requestJson<unknown>(testConfig, {
|
||||
const requestOpts = {
|
||||
url: chatEndpoint(testConfig.baseUrl),
|
||||
method: "POST",
|
||||
timeout: Math.min(config.timeout, 30),
|
||||
body: {
|
||||
model: "qwen3.7-max",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
await requestJson<unknown>(testConfig, requestOpts);
|
||||
break;
|
||||
} catch (err) {
|
||||
if (attempt >= 3 || !canRetry(err)) {
|
||||
process.stderr.write("\n");
|
||||
throw new BailianError("API key validation failed", ExitCode.AUTH, "Invalid API key.", {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
// retry delay: 500ms, 1000ms, 2000ms
|
||||
const delayMs = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
process.stderr.write("Valid\n");
|
||||
|
||||
const existing = readConfigFile() as Record<string, unknown>;
|
||||
@@ -228,110 +78,6 @@ async function validateKeyAndPersist(config: Config, key: string): Promise<void>
|
||||
process.stderr.write(`Saved to ${getConfigPath()}\n`);
|
||||
}
|
||||
|
||||
/** Listens on 127.0.0.1:<port> so the console can reach the address passed to the browser. */
|
||||
async function runConsoleLogin(consoleOrigin: string): Promise<void> {
|
||||
const state = randomBytes(16).toString("hex");
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204, {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const u = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (u.searchParams.get("state") !== state) {
|
||||
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("bad state\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const accessToken = await extractAccessTokenFromRequest(req);
|
||||
|
||||
if (accessToken) {
|
||||
try {
|
||||
const existing = readConfigFile() as Record<string, unknown>;
|
||||
existing.access_token = accessToken;
|
||||
await writeConfigFile(existing);
|
||||
process.stderr.write(`access_token saved to ${getConfigPath()}\n`);
|
||||
} catch {
|
||||
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("Failed to save access_token\n");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
});
|
||||
res.end("OK\n");
|
||||
|
||||
if (accessToken) {
|
||||
server.close();
|
||||
}
|
||||
} catch {
|
||||
res.statusCode = 500;
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
let port: number;
|
||||
try {
|
||||
port = await listenServerOnFreeLocalPort(server);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new BailianError(
|
||||
`Could not bind to 127.0.0.1 (no free port or permission denied): ${msg}`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
const loginUrl = `${consoleOrigin}/console-login?notice=127.0.0.1:${port}?state=${encodeURIComponent(state)}`;
|
||||
|
||||
try {
|
||||
await openInBrowser(loginUrl);
|
||||
process.stderr.write(
|
||||
"Opened the login page in your default browser. This process keeps the local port open for the console; press Ctrl+C when finished (or wait for idle timeout).\n",
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(
|
||||
`Could not open the default browser (${msg}). Open this URL manually:\n\n`,
|
||||
);
|
||||
process.stdout.write(`${loginUrl}\n`);
|
||||
process.stderr.write(
|
||||
"\nThis process keeps the local port open for the console; press Ctrl+C when finished (or wait for idle timeout).\n",
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let finished = false;
|
||||
const done = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
server.close();
|
||||
}, CONSOLE_LOGIN_TIMEOUT_MS);
|
||||
|
||||
server.once("close", done);
|
||||
server.once("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
if (!finished) {
|
||||
finished = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
name: "auth login",
|
||||
description: "Authenticate with API key or console browser login (credentials can coexist)",
|
||||
@@ -353,7 +99,11 @@ export default defineCommand({
|
||||
);
|
||||
return;
|
||||
}
|
||||
await runConsoleLogin(resolveConsoleOrigin());
|
||||
const hasApiKey = !!(config.apiKey || config.fileApiKey);
|
||||
await runConsoleLogin(resolveConsoleOrigin(), {
|
||||
needApiKey: !hasApiKey,
|
||||
onApiKey: (key) => validateKeyAndPersist(config, key),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
maskToken,
|
||||
readConfigFile,
|
||||
writeConfigFile,
|
||||
BailianError,
|
||||
@@ -28,6 +29,11 @@ const VALID_KEYS = [
|
||||
"workspace_id",
|
||||
];
|
||||
|
||||
// Keys whose values are secrets. Their stored value must never be echoed back in
|
||||
// cleartext (CI logs, pipes, shared terminals); show a masked form instead — the
|
||||
// same policy `config show` and `auth status` already follow.
|
||||
const SECRET_KEYS = new Set(["api_key", "access_token", "access_key_id", "access_key_secret"]);
|
||||
|
||||
// Allow hyphen-style keys (e.g. default-text-model → default_text_model)
|
||||
const KEY_ALIASES: Record<string, string> = {
|
||||
"base-url": "base_url",
|
||||
@@ -120,7 +126,10 @@ export default defineCommand({
|
||||
await writeConfigFile(existing);
|
||||
|
||||
if (!config.quiet) {
|
||||
emitResult({ [resolvedKey]: existing[resolvedKey] }, format);
|
||||
const shown = SECRET_KEYS.has(resolvedKey)
|
||||
? maskToken(String(existing[resolvedKey]))
|
||||
: existing[resolvedKey];
|
||||
emitResult({ [resolvedKey]: shown }, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
defineCommand,
|
||||
signRequest,
|
||||
detectOutputFormat,
|
||||
maskToken,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
type KnowledgeRetrieveRequest,
|
||||
@@ -105,7 +106,7 @@ export default defineCommand({
|
||||
|
||||
if (config.verbose) {
|
||||
process.stderr.write(`> POST ${url}\n`);
|
||||
process.stderr.write(`> AK: ${accessKeyId.slice(0, 8)}...\n`);
|
||||
process.stderr.write(`> AK: ${maskToken(accessKeyId)}\n`);
|
||||
}
|
||||
|
||||
const timeoutMs = config.timeout * 1000;
|
||||
|
||||
@@ -289,25 +289,10 @@ function resolvePlannedExpression(
|
||||
return combineResolved(undefined, undefined, false, false);
|
||||
}
|
||||
if ("$js" in expression) {
|
||||
const argsExpressions = (expression.args ?? {}) as Record<string, PipelineInputExpression>;
|
||||
const hasFrom = Object.values(argsExpressions).some((v) => isRecord(v) && "$from" in v);
|
||||
if (hasFrom) return combineResolved({ ...expression }, { ...expression }, false);
|
||||
const code = expression.$js as string;
|
||||
const resolvedArgs: Record<string, unknown> = {};
|
||||
let sensitive = false;
|
||||
for (const [key, argExpr] of Object.entries(argsExpressions)) {
|
||||
const resolved = resolvePlannedExpression(argExpr, pipeline, runtimeInput);
|
||||
resolvedArgs[key] = resolved.value;
|
||||
sensitive = sensitive || resolved.sensitive;
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
||||
const fn = new Function("args", `return (${code})`);
|
||||
const value = fn(resolvedArgs);
|
||||
return combineResolved(value, sensitive ? REDACTED : value, sensitive);
|
||||
} catch {
|
||||
return combineResolved({ ...expression }, { ...expression }, false);
|
||||
}
|
||||
// Planning / dry-run must be a non-executing preview: never run user
|
||||
// JavaScript here. Surface the expression as an unresolved placeholder so a
|
||||
// `--dry-run` of an untrusted pipeline cannot trigger code execution.
|
||||
return combineResolved({ ...expression }, { ...expression }, false);
|
||||
}
|
||||
return combineResolved(expression, expression, false);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ export function orderReports(
|
||||
return [...reports].sort((a, b) => (index.get(a.id) ?? 0) - (index.get(b.id) ?? 0));
|
||||
}
|
||||
|
||||
const MAX_CONCURRENCY = 64;
|
||||
|
||||
export function normalizeConcurrency(value: number | undefined): number {
|
||||
if (value === undefined) return 1;
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
@@ -77,5 +79,7 @@ export function normalizeConcurrency(value: number | undefined): number {
|
||||
details: { issues: ["concurrency must be a positive integer"] },
|
||||
});
|
||||
}
|
||||
return value;
|
||||
// Cap fan-out so a single run cannot launch an unbounded number of concurrent
|
||||
// API calls / downloads and exhaust sockets, file descriptors, or memory.
|
||||
return Math.min(value, MAX_CONCURRENCY);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,14 @@ export function getByJsonPointer(value: unknown, pointer: string): unknown {
|
||||
continue;
|
||||
}
|
||||
if (isRecord(current)) {
|
||||
// A JSON pointer over data must not reach object internals. Block
|
||||
// prototype-polluting keys and only follow own properties so a crafted
|
||||
// `$from`/`$input` path cannot pull out `constructor`/`__proto__` and feed
|
||||
// it into downstream consumers.
|
||||
if (segment === "__proto__" || segment === "constructor" || segment === "prototype") {
|
||||
return undefined;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(current, segment)) return undefined;
|
||||
current = current[segment];
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -125,6 +125,21 @@ function collectPipelineSemanticIssues(
|
||||
`semantic: step "${stepLabel}" timeout must be a positive number of seconds or duration string`,
|
||||
);
|
||||
}
|
||||
|
||||
// `script/js` executes its `code` as host JavaScript. Require it to be a
|
||||
// literal string in the pipeline definition: code sourced from another step
|
||||
// ($from) or any expression is rejected, so untrusted/model-generated text
|
||||
// can never become the body of the executed function.
|
||||
if (step.type === "script/js") {
|
||||
const code = isRecord(step.input) ? step.input.code : undefined;
|
||||
if (typeof code !== "string") {
|
||||
issues.push(
|
||||
`semantic: step "${stepLabel}" (script/js) requires a literal string "code"; ` +
|
||||
`code sourced from another step ($from) or an expression is not allowed, ` +
|
||||
`since it would execute untrusted text as host code`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check dependency references
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { createStepDispatcher } from "../src/pipeline/dispatcher.ts";
|
||||
import { executePipeline } from "../src/pipeline/executor.ts";
|
||||
import { collectPipelineIssues } from "../src/pipeline/validation.ts";
|
||||
import { getByJsonPointer } from "../src/pipeline/schema.ts";
|
||||
import { normalizeConcurrency } from "../src/pipeline/scheduler.ts";
|
||||
import { WORKFLOW_VERSION, type PipelineDefinition } from "../src/pipeline/types.ts";
|
||||
|
||||
test("cli package skeleton", () => {
|
||||
@@ -34,3 +37,73 @@ test("pipeline execution can use an isolated step dispatcher", async () => {
|
||||
hasSignal: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("dry-run never executes $js expressions (preview must not run code)", async () => {
|
||||
const dispatcher = createStepDispatcher();
|
||||
dispatcher.registerStep("test/echo", (input) => ({ data: input }));
|
||||
const flag = "__bailian_dryrun_should_not_run__";
|
||||
delete (globalThis as Record<string, unknown>)[flag];
|
||||
|
||||
const pipeline: PipelineDefinition = {
|
||||
version: WORKFLOW_VERSION,
|
||||
steps: [
|
||||
{
|
||||
id: "s1",
|
||||
type: "test/echo",
|
||||
input: { probe: { $js: `(globalThis[${JSON.stringify(flag)}] = true), 1` } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const report = await executePipeline(pipeline, {}, { stepDispatcher: dispatcher, dryRun: true });
|
||||
expect(report.status).toBe("planned");
|
||||
expect((globalThis as Record<string, unknown>)[flag]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("script/js rejects non-literal code sourced from another step ($from)", () => {
|
||||
const dispatcher = createStepDispatcher();
|
||||
dispatcher.registerStep("test/echo", (input) => ({ data: input }));
|
||||
dispatcher.registerStep("script/js", () => ({ data: {} }));
|
||||
|
||||
const pipeline: PipelineDefinition = {
|
||||
version: WORKFLOW_VERSION,
|
||||
steps: [
|
||||
{ id: "gen", type: "test/echo", input: { message: "x" } },
|
||||
{
|
||||
id: "run",
|
||||
type: "script/js",
|
||||
input: { code: { $from: "gen", path: "/data/message" } as never },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const issues = collectPipelineIssues(pipeline, dispatcher);
|
||||
expect(issues.some((issue) => issue.includes('literal string "code"'))).toBe(true);
|
||||
});
|
||||
|
||||
test("script/js accepts a literal string code", () => {
|
||||
const dispatcher = createStepDispatcher();
|
||||
dispatcher.registerStep("script/js", () => ({ data: {} }));
|
||||
|
||||
const pipeline: PipelineDefinition = {
|
||||
version: WORKFLOW_VERSION,
|
||||
steps: [{ id: "run", type: "script/js", input: { code: "return 1" } }],
|
||||
};
|
||||
|
||||
expect(collectPipelineIssues(pipeline, dispatcher)).toEqual([]);
|
||||
});
|
||||
|
||||
test("getByJsonPointer refuses prototype keys and inherited properties", () => {
|
||||
const obj = { a: { b: 1 } };
|
||||
expect(getByJsonPointer(obj, "/a/b")).toBe(1);
|
||||
expect(getByJsonPointer(obj, "/__proto__")).toBeUndefined();
|
||||
expect(getByJsonPointer(obj, "/constructor")).toBeUndefined();
|
||||
expect(getByJsonPointer(obj, "/a/constructor/constructor")).toBeUndefined();
|
||||
expect(getByJsonPointer(obj, "/toString")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("normalizeConcurrency clamps to a safe maximum", () => {
|
||||
expect(normalizeConcurrency(undefined)).toBe(1);
|
||||
expect(normalizeConcurrency(4)).toBe(4);
|
||||
expect(normalizeConcurrency(100000)).toBe(64);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user