Merge branch 'main' into feat/mcp-command

This commit is contained in:
若麒
2026-06-05 00:00:45 +08:00
39 changed files with 1314 additions and 729 deletions
+11 -11
View File
@@ -9,7 +9,7 @@
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](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 -1
View File
@@ -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;
}
}
+42 -292
View File
@@ -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;
}
+10 -1
View File
@@ -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;
+4 -19
View File
@@ -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);
}
+5 -1
View File
@@ -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);
}
+8
View File
@@ -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;
}
+15
View File
@@ -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
+73
View File
@@ -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);
});