feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道
Refs #225 Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
418
canvas-agent/src/dreamina-cli.ts
Normal file
418
canvas-agent/src/dreamina-cli.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
|
||||
import { CONFIG_DIR } from "./config.js";
|
||||
import { DreaminaCliArbiter, type DreaminaCliInvocationLease, type DreaminaCliSessionSnapshot } from "./dreamina-cli-arbiter.js";
|
||||
import {
|
||||
discoverDreaminaExecutable,
|
||||
DreaminaCliError,
|
||||
runDreaminaProcess,
|
||||
type DreaminaProcessRequest,
|
||||
type DreaminaProcessResult,
|
||||
} from "./dreamina-cli-process.js";
|
||||
|
||||
export type { DreaminaProcessRequest, DreaminaProcessResult } from "./dreamina-cli-process.js";
|
||||
|
||||
type DreaminaInstallation = { installed: true; executable: string } | { installed: false };
|
||||
type DreaminaState = "missing" | "installed" | "login_pending" | "authenticated" | "error";
|
||||
export type DreaminaPublicStatus = {
|
||||
provider: "dreamina-cli";
|
||||
state: DreaminaState;
|
||||
installed: boolean;
|
||||
authenticated: boolean;
|
||||
version?: string;
|
||||
totalCredit?: number;
|
||||
accountBinding?: string;
|
||||
sessionEpoch?: number;
|
||||
creditObservedAt?: string;
|
||||
code?: string;
|
||||
message: string;
|
||||
verificationUri?: string;
|
||||
userCode?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
|
||||
type PrivateLoginFlow = {
|
||||
verificationUri: string;
|
||||
userCode: string;
|
||||
deviceCode: string;
|
||||
expiresAt: number;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
export type DreaminaCliServiceOptions = {
|
||||
ownerId: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
discover?: (signal?: AbortSignal) => Promise<DreaminaInstallation>;
|
||||
runProcess?: (request: DreaminaProcessRequest) => Promise<DreaminaProcessResult>;
|
||||
now?: () => Date;
|
||||
arbiter?: DreaminaCliArbiter;
|
||||
arbiterStateFile?: string;
|
||||
};
|
||||
|
||||
export type DreaminaLifecycleOptions = { signal?: AbortSignal };
|
||||
|
||||
export class DreaminaCliService {
|
||||
private readonly env: Record<string, string | undefined>;
|
||||
private readonly discover: (signal?: AbortSignal) => Promise<DreaminaInstallation>;
|
||||
private readonly runProcess: (request: DreaminaProcessRequest) => Promise<DreaminaProcessResult>;
|
||||
private readonly now: () => Date;
|
||||
private readonly arbiter: DreaminaCliArbiter;
|
||||
private flow?: PrivateLoginFlow;
|
||||
private epoch = 0;
|
||||
private readonly activeControllers = new Set<AbortController>();
|
||||
|
||||
constructor(options: DreaminaCliServiceOptions) {
|
||||
if (!/^[A-Za-z0-9._-]{16,120}$/.test(options.ownerId)) {
|
||||
throw new Error("Dreamina owner is invalid");
|
||||
}
|
||||
// ownerId intentionally validates the Runtime ownership boundary only. It never
|
||||
// enters CLI argv/env and therefore cannot select an account or CLI home.
|
||||
this.env = options.env ?? process.env;
|
||||
this.discover = options.discover ?? ((signal) => discoverDreaminaExecutable(this.env, signal));
|
||||
this.runProcess = options.runProcess ?? runDreaminaProcess;
|
||||
this.now = options.now ?? (() => new Date());
|
||||
this.arbiter = options.arbiter ?? new DreaminaCliArbiter({
|
||||
stateFile: path.resolve(options.arbiterStateFile ?? path.join(CONFIG_DIR, "dreamina-cli-arbiter.json")),
|
||||
});
|
||||
}
|
||||
|
||||
status(options: DreaminaLifecycleOptions = {}): Promise<DreaminaPublicStatus> {
|
||||
return this.statusWithSession(options).then((result) => scopedPublicStatus(result.status, result.session, this.now()));
|
||||
}
|
||||
|
||||
statusWithSession(options: DreaminaLifecycleOptions = {}): Promise<{ status: DreaminaPublicStatus; session: DreaminaCliSessionSnapshot }> {
|
||||
return this.readStatus(this.epoch, options.signal);
|
||||
}
|
||||
|
||||
login(options: DreaminaLifecycleOptions = {}): Promise<DreaminaPublicStatus> {
|
||||
return this.startLogin(this.epoch, options.signal);
|
||||
}
|
||||
|
||||
logout(options: DreaminaLifecycleOptions = {}): Promise<DreaminaPublicStatus> {
|
||||
const epoch = ++this.epoch;
|
||||
this.flow = undefined;
|
||||
for (const controller of this.activeControllers) controller.abort();
|
||||
return this.finishLogout(epoch, options.signal);
|
||||
}
|
||||
|
||||
private async readStatus(epoch: number, signal?: AbortSignal): Promise<{ status: DreaminaPublicStatus; session: DreaminaCliSessionSnapshot }> {
|
||||
const context = await this.prepare(epoch, signal);
|
||||
if (!context.installed) return { status: missingStatus(), session: context.session };
|
||||
if (context.versionError) {
|
||||
return {
|
||||
status: publicStatus("error", true, false, context.version, "dreamina_version_failed", "已找到 Dreamina CLI,但版本检测失败"),
|
||||
session: context.session,
|
||||
};
|
||||
}
|
||||
if (this.flow && this.flow.expiresAt > this.now().getTime()) {
|
||||
const checked = await this.executeInvocation(
|
||||
context.executable,
|
||||
["login", "checklogin", `--device_code=${this.flow.deviceCode}`, "--poll=0"],
|
||||
15_000,
|
||||
epoch,
|
||||
signal,
|
||||
async (result, lease) => result.exitCode === 0 ? this.ensureAuthenticatedSession(lease) : lease.session,
|
||||
);
|
||||
if (checked.result.exitCode === 0) {
|
||||
this.assertEpoch(epoch);
|
||||
this.flow = undefined;
|
||||
return {
|
||||
status: publicStatus("authenticated", true, true, context.version, undefined, "Dreamina CLI 已登录"),
|
||||
session: checked.session,
|
||||
};
|
||||
}
|
||||
return { status: this.publicFlow(context.version), session: checked.session };
|
||||
}
|
||||
this.assertEpoch(epoch);
|
||||
this.flow = undefined;
|
||||
const auth = await this.executeInvocation(
|
||||
context.executable,
|
||||
["user_credit"],
|
||||
15_000,
|
||||
epoch,
|
||||
signal,
|
||||
async (result, lease) => {
|
||||
if (result.exitCode === 0) return this.ensureAuthenticatedSession(lease);
|
||||
return lease.session.accountBinding ? this.arbiter.advanceSession(lease) : lease.session;
|
||||
},
|
||||
);
|
||||
if (auth.result.exitCode === 0) {
|
||||
const status = publicStatus("authenticated", true, true, context.version, undefined, "Dreamina CLI 已登录");
|
||||
const totalCredit = parseTotalCredit(auth.result.stdout);
|
||||
return { status: totalCredit === undefined ? status : { ...status, totalCredit }, session: auth.session };
|
||||
}
|
||||
return {
|
||||
status: publicStatus("installed", true, false, context.version, "dreamina_login_required", "Dreamina CLI 已安装,需要登录"),
|
||||
session: auth.session,
|
||||
};
|
||||
}
|
||||
|
||||
private async finishLogout(epoch: number, signal?: AbortSignal): Promise<DreaminaPublicStatus> {
|
||||
this.assertEpoch(epoch);
|
||||
assertLifecycleSignal(signal);
|
||||
const installation = await this.discover(signal);
|
||||
this.assertEpoch(epoch);
|
||||
assertLifecycleSignal(signal);
|
||||
if (!installation.installed) return missingStatus();
|
||||
const executed = await this.executeInvocation(
|
||||
installation.executable,
|
||||
["logout"],
|
||||
15_000,
|
||||
epoch,
|
||||
signal,
|
||||
async (result, lease) => result.exitCode === 0 ? this.arbiter.advanceSession(lease) : lease.session,
|
||||
);
|
||||
if (executed.result.exitCode !== 0) {
|
||||
throw new DreaminaCliError("dreamina_logout_failed", "Dreamina 退出失败,请重试", 502);
|
||||
}
|
||||
return publicStatus(
|
||||
"installed",
|
||||
true,
|
||||
false,
|
||||
undefined,
|
||||
"dreamina_login_required",
|
||||
"Dreamina CLI 已退出登录",
|
||||
);
|
||||
}
|
||||
|
||||
private async startLogin(epoch: number, signal?: AbortSignal) {
|
||||
this.assertEpoch(epoch);
|
||||
assertLifecycleSignal(signal);
|
||||
if (this.flow && this.flow.expiresAt > this.now().getTime()) return this.publicFlow();
|
||||
const context = await this.prepare(epoch, signal);
|
||||
if (!context.installed) {
|
||||
throw new DreaminaCliError("dreamina_missing", "未检测到 Dreamina CLI", 404);
|
||||
}
|
||||
if (context.versionError) {
|
||||
throw new DreaminaCliError("dreamina_version_failed", "Dreamina CLI 版本检测失败", 502);
|
||||
}
|
||||
const executed = await this.executeInvocation(
|
||||
context.executable,
|
||||
["login", "--headless"],
|
||||
20_000,
|
||||
epoch,
|
||||
signal,
|
||||
async (result, lease) => result.exitCode === 0 ? this.arbiter.advanceSession(lease) : lease.session,
|
||||
);
|
||||
if (executed.result.exitCode !== 0) {
|
||||
throw new DreaminaCliError("dreamina_login_failed", "无法启动 Dreamina OAuth 登录", 502);
|
||||
}
|
||||
this.assertEpoch(epoch);
|
||||
this.flow = {
|
||||
...parseLoginFlow(executed.result.stdout, this.now().getTime()),
|
||||
...(context.version ? { version: context.version } : {}),
|
||||
};
|
||||
return this.publicFlow(context.version);
|
||||
}
|
||||
|
||||
private async prepare(epoch: number, signal?: AbortSignal) {
|
||||
this.assertEpoch(epoch);
|
||||
assertLifecycleSignal(signal);
|
||||
const installation = await this.discover(signal);
|
||||
this.assertEpoch(epoch);
|
||||
assertLifecycleSignal(signal);
|
||||
if (!installation.installed) return { installed: false as const, session: await this.arbiter.readSession(signal) };
|
||||
const version = await this.executeInvocation(installation.executable, ["--version"], 8_000, epoch, signal);
|
||||
return {
|
||||
installed: true as const,
|
||||
executable: installation.executable,
|
||||
version: extractVersion(version.result.stdout),
|
||||
versionError: version.result.exitCode !== 0,
|
||||
session: version.session,
|
||||
};
|
||||
}
|
||||
|
||||
private async executeInvocation(
|
||||
executable: string,
|
||||
args: string[],
|
||||
timeoutMs: number,
|
||||
epoch: number,
|
||||
signal?: AbortSignal,
|
||||
commit?: (result: DreaminaProcessResult, lease: DreaminaCliInvocationLease) => Promise<DreaminaCliSessionSnapshot>,
|
||||
) {
|
||||
this.assertEpoch(epoch);
|
||||
assertLifecycleSignal(signal);
|
||||
const invocation = await this.arbiter.acquire({ signal });
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
this.activeControllers.add(controller);
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
if (signal?.aborted) abort();
|
||||
try {
|
||||
const result = await this.runProcess({ executable, args, timeoutMs, signal: controller.signal, env: this.env });
|
||||
this.assertEpoch(epoch);
|
||||
await invocation.assertCurrent();
|
||||
const session = commit ? await commit(result, invocation) : invocation.session;
|
||||
await invocation.assertCurrent(session);
|
||||
return { result, session };
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
this.activeControllers.delete(controller);
|
||||
await invocation.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureAuthenticatedSession(lease: DreaminaCliInvocationLease) {
|
||||
if (lease.session.accountBinding) return lease.session;
|
||||
return this.arbiter.commitSession(lease, crypto.randomBytes(32).toString("hex"));
|
||||
}
|
||||
|
||||
private assertEpoch(epoch: number) {
|
||||
if (epoch !== this.epoch) {
|
||||
throw new DreaminaCliError("dreamina_cancelled", "Dreamina 操作已取消", 499);
|
||||
}
|
||||
}
|
||||
|
||||
private publicFlow(version = this.flow?.version): DreaminaPublicStatus {
|
||||
if (!this.flow) {
|
||||
return publicStatus(
|
||||
"installed",
|
||||
true,
|
||||
false,
|
||||
version,
|
||||
"dreamina_login_required",
|
||||
"Dreamina CLI 需要登录",
|
||||
);
|
||||
}
|
||||
return {
|
||||
...publicStatus(
|
||||
"login_pending",
|
||||
true,
|
||||
false,
|
||||
version,
|
||||
"dreamina_login_pending",
|
||||
"请在官方页面确认 Dreamina 登录",
|
||||
),
|
||||
verificationUri: this.flow.verificationUri,
|
||||
userCode: this.flow.userCode,
|
||||
expiresAt: new Date(this.flow.expiresAt).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function assertLifecycleSignal(signal?: AbortSignal) {
|
||||
if (signal?.aborted) {
|
||||
throw new DreaminaCliError("dreamina_cancelled", "Dreamina 操作已取消", 499);
|
||||
}
|
||||
}
|
||||
|
||||
function parseLoginFlow(stdout: string, now: number): PrivateLoginFlow {
|
||||
let payload: Record<string, unknown> | undefined;
|
||||
try {
|
||||
const start = stdout.indexOf("{");
|
||||
const end = stdout.lastIndexOf("}");
|
||||
if (start >= 0 && end > start) {
|
||||
payload = JSON.parse(stdout.slice(start, end + 1)) as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
payload = undefined;
|
||||
}
|
||||
const verificationUri = stringValue(payload, "verification_uri")
|
||||
|| matchValue(stdout, /verification_uri\s*[:=]\s*(https?:\/\/\S+)/i);
|
||||
const userCode = stringValue(payload, "user_code")
|
||||
|| matchValue(stdout, /user_code\s*[:=]\s*([A-Za-z0-9-]+)/i);
|
||||
const deviceCode = stringValue(payload, "device_code")
|
||||
|| matchValue(stdout, /device_code\s*[:=]\s*([^\s]+)/i);
|
||||
const expiresIn = Math.min(1_800, Math.max(60, Number(payload?.expires_in) || 600));
|
||||
if (!isOfficialVerificationUri(verificationUri)
|
||||
|| !userCode
|
||||
|| !/^[A-Za-z0-9-]{4,32}$/.test(userCode)
|
||||
|| !deviceCode
|
||||
|| deviceCode.length > 2_048) {
|
||||
throw new DreaminaCliError(
|
||||
"dreamina_login_response_invalid",
|
||||
"Dreamina 登录响应无法识别,请升级 CLI",
|
||||
502,
|
||||
);
|
||||
}
|
||||
return { verificationUri, userCode, deviceCode, expiresAt: now + expiresIn * 1_000 };
|
||||
}
|
||||
|
||||
function isOfficialVerificationUri(value: string) {
|
||||
if (!value || value.length > 2_048) return false;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "https:"
|
||||
&& url.hostname === "jimeng.jianying.com"
|
||||
&& url.pathname === "/ai-tool/cli-auth"
|
||||
&& !url.username
|
||||
&& !url.password;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(payload: Record<string, unknown> | undefined, key: string) {
|
||||
const value = payload?.[key];
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function matchValue(value: string, pattern: RegExp) {
|
||||
return pattern.exec(value)?.[1]?.replace(/[",]+$/, "") || "";
|
||||
}
|
||||
|
||||
function extractVersion(stdout: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as { version?: unknown };
|
||||
if (typeof parsed.version === "string"
|
||||
&& /^[A-Za-z0-9._+:-]{1,120}$/.test(parsed.version)) {
|
||||
return parsed.version;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the bounded plain-text marker.
|
||||
}
|
||||
return /(?:\bversion\s*[:=]?\s*|\bv)([0-9][A-Za-z0-9._+:-]{0,119})\b/i.exec(stdout)?.[1];
|
||||
}
|
||||
|
||||
function parseTotalCredit(stdout: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as Record<string, unknown>;
|
||||
const value = parsed.total_credit;
|
||||
return Number.isInteger(value) && (value as number) >= 0 && (value as number) <= 1_000_000_000
|
||||
? value as number
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function scopedPublicStatus(status: DreaminaPublicStatus, session: DreaminaCliSessionSnapshot, observedAt: Date): DreaminaPublicStatus {
|
||||
if (!status.authenticated || !session.accountBinding) return status;
|
||||
return {
|
||||
...status,
|
||||
accountBinding: session.accountBinding,
|
||||
sessionEpoch: session.sessionEpoch,
|
||||
...(status.totalCredit === undefined ? {} : { creditObservedAt: observedAt.toISOString() }),
|
||||
};
|
||||
}
|
||||
|
||||
function publicStatus(
|
||||
state: DreaminaState,
|
||||
installed: boolean,
|
||||
authenticated: boolean,
|
||||
version: string | undefined,
|
||||
code: string | undefined,
|
||||
message: string,
|
||||
): DreaminaPublicStatus {
|
||||
return {
|
||||
provider: "dreamina-cli",
|
||||
state,
|
||||
installed,
|
||||
authenticated,
|
||||
...(version ? { version } : {}),
|
||||
...(code ? { code } : {}),
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function missingStatus(): DreaminaPublicStatus {
|
||||
return publicStatus(
|
||||
"missing",
|
||||
false,
|
||||
false,
|
||||
undefined,
|
||||
"dreamina_missing",
|
||||
"未检测到 Dreamina CLI,请先完成本机安装",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user