Merge branch 'openclaw:main' into qianfan

This commit is contained in:
ide-rea
2026-02-05 12:43:21 +08:00
committed by GitHub
219 changed files with 5306 additions and 2225 deletions

View File

@@ -430,6 +430,7 @@ export async function agentCommand(
currentThreadTs: runContext.currentThreadTs,
replyToMode: runContext.replyToMode,
hasRepliedRef: runContext.hasRepliedRef,
senderIsOwner: true,
sessionFile,
workspaceDir,
config: cfg,

View File

@@ -0,0 +1,179 @@
import { spawnSync } from "node:child_process";
import path from "node:path";
import type { RuntimeEnv } from "../runtime.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
import { resolveCliName } from "../cli/cli-name.js";
import {
completionCacheExists,
installCompletion,
isCompletionInstalled,
resolveCompletionCachePath,
resolveShellFromEnv,
usesSlowDynamicCompletion,
} from "../cli/completion-cli.js";
import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js";
import { note } from "../terminal/note.js";
type CompletionShell = "zsh" | "bash" | "fish" | "powershell";
/** Generate the completion cache by spawning the CLI. */
async function generateCompletionCache(): Promise<boolean> {
const root = await resolveOpenClawPackageRoot({
moduleUrl: import.meta.url,
argv1: process.argv[1],
cwd: process.cwd(),
});
if (!root) {
return false;
}
const binPath = path.join(root, "openclaw.mjs");
const result = spawnSync(process.execPath, [binPath, "completion", "--write-state"], {
cwd: root,
env: process.env,
encoding: "utf-8",
});
return result.status === 0;
}
export type ShellCompletionStatus = {
shell: CompletionShell;
profileInstalled: boolean;
cacheExists: boolean;
cachePath: string;
/** True if profile uses slow dynamic pattern like `source <(openclaw completion ...)` */
usesSlowPattern: boolean;
};
/** Check the status of shell completion for the current shell. */
export async function checkShellCompletionStatus(
binName = "openclaw",
): Promise<ShellCompletionStatus> {
const shell = resolveShellFromEnv() as CompletionShell;
const profileInstalled = await isCompletionInstalled(shell, binName);
const cacheExists = await completionCacheExists(shell, binName);
const cachePath = resolveCompletionCachePath(shell, binName);
const usesSlowPattern = await usesSlowDynamicCompletion(shell, binName);
return {
shell,
profileInstalled,
cacheExists,
cachePath,
usesSlowPattern,
};
}
export type DoctorCompletionOptions = {
nonInteractive?: boolean;
};
/**
* Doctor check for shell completion.
* - If profile uses slow dynamic pattern: upgrade to cached version
* - If profile has completion but no cache: auto-generate cache and upgrade profile
* - If no completion at all: prompt to install (with user confirmation)
*/
export async function doctorShellCompletion(
runtime: RuntimeEnv,
prompter: DoctorPrompter,
options: DoctorCompletionOptions = {},
): Promise<void> {
const cliName = resolveCliName();
const status = await checkShellCompletionStatus(cliName);
// Profile uses slow dynamic pattern - upgrade to cached version
if (status.usesSlowPattern) {
note(
`Your ${status.shell} profile uses slow dynamic completion (source <(...)).\nUpgrading to cached completion for faster shell startup...`,
"Shell completion",
);
// Ensure cache exists first
if (!status.cacheExists) {
const generated = await generateCompletionCache();
if (!generated) {
note(
`Failed to generate completion cache. Run \`${cliName} completion --write-state\` manually.`,
"Shell completion",
);
return;
}
}
// Upgrade profile to use cached file
await installCompletion(status.shell, true, cliName);
note(
`Shell completion upgraded. Restart your shell or run: source ~/.${status.shell === "zsh" ? "zshrc" : status.shell === "bash" ? "bashrc" : "config/fish/config.fish"}`,
"Shell completion",
);
return;
}
// Profile has completion but no cache - auto-fix
if (status.profileInstalled && !status.cacheExists) {
note(
`Shell completion is configured in your ${status.shell} profile but the cache is missing.\nRegenerating cache...`,
"Shell completion",
);
const generated = await generateCompletionCache();
if (generated) {
note(`Completion cache regenerated at ${status.cachePath}`, "Shell completion");
} else {
note(
`Failed to regenerate completion cache. Run \`${cliName} completion --write-state\` manually.`,
"Shell completion",
);
}
return;
}
// No completion at all - prompt to install
if (!status.profileInstalled) {
if (options.nonInteractive) {
// In non-interactive mode, just note that completion is not installed
return;
}
const shouldInstall = await prompter.confirm({
message: `Enable ${status.shell} shell completion for ${cliName}?`,
initialValue: true,
});
if (shouldInstall) {
// First generate the cache
const generated = await generateCompletionCache();
if (!generated) {
note(
`Failed to generate completion cache. Run \`${cliName} completion --write-state\` manually.`,
"Shell completion",
);
return;
}
// Then install to profile
await installCompletion(status.shell, true, cliName);
note(
`Shell completion installed. Restart your shell or run: source ~/.${status.shell === "zsh" ? "zshrc" : status.shell === "bash" ? "bashrc" : "config/fish/config.fish"}`,
"Shell completion",
);
}
}
}
/**
* Ensure completion cache exists. Used during onboarding/update to fix
* cases where profile has completion but no cache.
* This is a silent fix - no prompts.
*/
export async function ensureCompletionCacheExists(binName = "openclaw"): Promise<boolean> {
const shell = resolveShellFromEnv() as CompletionShell;
const cacheExists = await completionCacheExists(shell, binName);
if (cacheExists) {
return true;
}
return generateCompletionCache();
}

View File

@@ -26,6 +26,7 @@ import {
maybeRepairAnthropicOAuthProfileId,
noteAuthProfileHealth,
} from "./doctor-auth.js";
import { doctorShellCompletion } from "./doctor-completion.js";
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
import { maybeRepairGatewayDaemon } from "./doctor-gateway-daemon-flow.js";
import { checkGatewayHealth } from "./doctor-gateway-health.js";
@@ -259,6 +260,11 @@ export async function doctorCommand(
noteWorkspaceStatus(cfg);
// Check and fix shell completion
await doctorShellCompletion(runtime, prompter, {
nonInteractive: options.nonInteractive,
});
const { healthOk } = await checkGatewayHealth({
runtime,
cfg,

View File

@@ -96,4 +96,96 @@ describe("onboard (non-interactive): Cloudflare AI Gateway", () => {
process.env.OPENCLAW_GATEWAY_PASSWORD = prev.password;
}
}, 60_000);
it("infers auth choice from API key flags", async () => {
const prev = {
home: process.env.HOME,
stateDir: process.env.OPENCLAW_STATE_DIR,
configPath: process.env.OPENCLAW_CONFIG_PATH,
skipChannels: process.env.OPENCLAW_SKIP_CHANNELS,
skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER,
skipCron: process.env.OPENCLAW_SKIP_CRON,
skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST,
token: process.env.OPENCLAW_GATEWAY_TOKEN,
password: process.env.OPENCLAW_GATEWAY_PASSWORD,
};
process.env.OPENCLAW_SKIP_CHANNELS = "1";
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
process.env.OPENCLAW_SKIP_CRON = "1";
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onboard-cf-gateway-infer-"));
process.env.HOME = tempHome;
process.env.OPENCLAW_STATE_DIR = tempHome;
process.env.OPENCLAW_CONFIG_PATH = path.join(tempHome, "openclaw.json");
vi.resetModules();
const runtime = {
log: () => {},
error: (msg: string) => {
throw new Error(msg);
},
exit: (code: number) => {
throw new Error(`exit:${code}`);
},
};
try {
const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js");
await runNonInteractiveOnboarding(
{
nonInteractive: true,
cloudflareAiGatewayAccountId: "cf-account-id",
cloudflareAiGatewayGatewayId: "cf-gateway-id",
cloudflareAiGatewayApiKey: "cf-gateway-test-key",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
);
const { CONFIG_PATH } = await import("../config/config.js");
const cfg = JSON.parse(await fs.readFile(CONFIG_PATH, "utf8")) as {
auth?: {
profiles?: Record<string, { provider?: string; mode?: string }>;
};
agents?: { defaults?: { model?: { primary?: string } } };
};
expect(cfg.auth?.profiles?.["cloudflare-ai-gateway:default"]?.provider).toBe(
"cloudflare-ai-gateway",
);
expect(cfg.auth?.profiles?.["cloudflare-ai-gateway:default"]?.mode).toBe("api_key");
expect(cfg.agents?.defaults?.model?.primary).toBe("cloudflare-ai-gateway/claude-sonnet-4-5");
const { ensureAuthProfileStore } = await import("../agents/auth-profiles.js");
const store = ensureAuthProfileStore();
const profile = store.profiles["cloudflare-ai-gateway:default"];
expect(profile?.type).toBe("api_key");
if (profile?.type === "api_key") {
expect(profile.provider).toBe("cloudflare-ai-gateway");
expect(profile.key).toBe("cf-gateway-test-key");
expect(profile.metadata).toEqual({
accountId: "cf-account-id",
gatewayId: "cf-gateway-id",
});
}
} finally {
await fs.rm(tempHome, { recursive: true, force: true });
process.env.HOME = prev.home;
process.env.OPENCLAW_STATE_DIR = prev.stateDir;
process.env.OPENCLAW_CONFIG_PATH = prev.configPath;
process.env.OPENCLAW_SKIP_CHANNELS = prev.skipChannels;
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = prev.skipGmail;
process.env.OPENCLAW_SKIP_CRON = prev.skipCron;
process.env.OPENCLAW_SKIP_CANVAS_HOST = prev.skipCanvas;
process.env.OPENCLAW_GATEWAY_TOKEN = prev.token;
process.env.OPENCLAW_GATEWAY_PASSWORD = prev.password;
}
}, 60_000);
});

View File

@@ -13,6 +13,7 @@ import {
resolveControlUiLinks,
waitForGatewayReachable,
} from "../onboard-helpers.js";
import { inferAuthChoiceFromFlags } from "./local/auth-choice-inference.js";
import { applyNonInteractiveAuthChoice } from "./local/auth-choice.js";
import { installGatewayDaemonNonInteractive } from "./local/daemon-install.js";
import { applyNonInteractiveGatewayConfig } from "./local/gateway-config.js";
@@ -49,7 +50,19 @@ export async function runNonInteractiveOnboardingLocal(params: {
},
};
const authChoice = opts.authChoice ?? "skip";
const inferredAuthChoice = inferAuthChoiceFromFlags(opts);
if (!opts.authChoice && inferredAuthChoice.matches.length > 1) {
runtime.error(
[
"Multiple API key flags were provided for non-interactive onboarding.",
"Use a single provider flag or pass --auth-choice explicitly.",
`Flags: ${inferredAuthChoice.matches.map((match) => match.label).join(", ")}`,
].join("\n"),
);
runtime.exit(1);
return;
}
const authChoice = opts.authChoice ?? inferredAuthChoice.choice ?? "skip";
const nextConfigAfterAuth = await applyNonInteractiveAuthChoice({
nextConfig,
authChoice,

View File

@@ -0,0 +1,67 @@
import type { AuthChoice, OnboardOptions } from "../../onboard-types.js";
type AuthChoiceFlag = {
flag: keyof AuthChoiceFlagOptions;
authChoice: AuthChoice;
label: string;
};
type AuthChoiceFlagOptions = Pick<
OnboardOptions,
| "anthropicApiKey"
| "geminiApiKey"
| "openaiApiKey"
| "openrouterApiKey"
| "aiGatewayApiKey"
| "cloudflareAiGatewayApiKey"
| "moonshotApiKey"
| "kimiCodeApiKey"
| "syntheticApiKey"
| "veniceApiKey"
| "zaiApiKey"
| "xiaomiApiKey"
| "minimaxApiKey"
| "opencodeZenApiKey"
>;
const AUTH_CHOICE_FLAG_MAP = [
{ flag: "anthropicApiKey", authChoice: "apiKey", label: "--anthropic-api-key" },
{ flag: "geminiApiKey", authChoice: "gemini-api-key", label: "--gemini-api-key" },
{ flag: "openaiApiKey", authChoice: "openai-api-key", label: "--openai-api-key" },
{ flag: "openrouterApiKey", authChoice: "openrouter-api-key", label: "--openrouter-api-key" },
{ flag: "aiGatewayApiKey", authChoice: "ai-gateway-api-key", label: "--ai-gateway-api-key" },
{
flag: "cloudflareAiGatewayApiKey",
authChoice: "cloudflare-ai-gateway-api-key",
label: "--cloudflare-ai-gateway-api-key",
},
{ flag: "moonshotApiKey", authChoice: "moonshot-api-key", label: "--moonshot-api-key" },
{ flag: "kimiCodeApiKey", authChoice: "kimi-code-api-key", label: "--kimi-code-api-key" },
{ flag: "syntheticApiKey", authChoice: "synthetic-api-key", label: "--synthetic-api-key" },
{ flag: "veniceApiKey", authChoice: "venice-api-key", label: "--venice-api-key" },
{ flag: "zaiApiKey", authChoice: "zai-api-key", label: "--zai-api-key" },
{ flag: "xiaomiApiKey", authChoice: "xiaomi-api-key", label: "--xiaomi-api-key" },
{ flag: "minimaxApiKey", authChoice: "minimax-api", label: "--minimax-api-key" },
{ flag: "opencodeZenApiKey", authChoice: "opencode-zen", label: "--opencode-zen-api-key" },
] satisfies ReadonlyArray<AuthChoiceFlag>;
export type AuthChoiceInference = {
choice?: AuthChoice;
matches: AuthChoiceFlag[];
};
// Infer auth choice from explicit provider API key flags.
export function inferAuthChoiceFromFlags(opts: OnboardOptions): AuthChoiceInference {
const matches = AUTH_CHOICE_FLAG_MAP.filter(({ flag }) => {
const value = opts[flag];
if (typeof value === "string") {
return value.trim().length > 0;
}
return Boolean(value);
});
return {
choice: matches[0]?.authChoice,
matches,
};
}

View File

@@ -1,3 +1,4 @@
import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js";
import type { RuntimeEnv } from "../runtime.js";
import { formatCliCommand } from "../cli/command-format.js";
import { withProgress } from "../cli/progress.js";
@@ -120,6 +121,14 @@ export async function statusCommand(
}),
)
: undefined;
const lastHeartbeat =
opts.deep && gatewayReachable
? await callGateway<HeartbeatEventPayload | null>({
method: "last-heartbeat",
params: {},
timeoutMs: opts.timeoutMs,
}).catch(() => null)
: null;
const configChannel = normalizeUpdateChannel(cfg.update?.channel);
const channelInfo = resolveEffectiveUpdateChannel({
@@ -157,7 +166,7 @@ export async function statusCommand(
nodeService: nodeDaemon,
agents: agentStatus,
securityAudit,
...(health || usage ? { health, usage } : {}),
...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}),
},
null,
2,
@@ -275,6 +284,21 @@ export async function statusCommand(
.filter(Boolean);
return parts.length > 0 ? parts.join(", ") : "disabled";
})();
const lastHeartbeatValue = (() => {
if (!opts.deep) {
return null;
}
if (!gatewayReachable) {
return warn("unavailable");
}
if (!lastHeartbeat) {
return muted("none");
}
const age = formatAge(Date.now() - lastHeartbeat.ts);
const channel = lastHeartbeat.channel ?? "unknown";
const accountLabel = lastHeartbeat.accountId ? `account ${lastHeartbeat.accountId}` : null;
return [lastHeartbeat.status, `${age} ago`, channel, accountLabel].filter(Boolean).join(" · ");
})();
const storeLabel =
summary.sessions.paths.length > 1
@@ -371,6 +395,7 @@ export async function statusCommand(
{ Item: "Probes", Value: probesValue },
{ Item: "Events", Value: eventsValue },
{ Item: "Heartbeat", Value: heartbeatValue },
...(lastHeartbeatValue ? [{ Item: "Last heartbeat", Value: lastHeartbeatValue }] : []),
{
Item: "Sessions",
Value: `${summary.sessions.count} active · default ${defaults.model ?? "unknown"}${defaultCtx} · ${storeLabel}`,