mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 04:51:10 +00:00
* fix(core): make indexed access explicit in auto-reply, infra, and config Part 1/3 of the src NUIA phase-3b burn-down (#104600): iteration and destructuring over index reads, boundary guards on parsed input, and named invariants. Config path walkers bind the path head once; SQLite migration key handling is hoisted without query-shape changes. * fix(core): make indexed access explicit in cli, gateway, commands, security, shared Part 2/3: argv/token selection restructured, gateway event/attachment invariants named, security parsers stay fail-closed (invariant violations throw), edit-distance matrices access checked entries. * fix(core): make indexed access explicit across remaining src surfaces Part 3/3: channels, plugins, process, cron, plugin-sdk, media, logging, tui, hooks, daemon, and small directories. Latent bug fixed: a tailnet resolver could leak undefined through a string|null contract and now fails with a descriptive local error. * fix(core): keep optional boundaries optional after per-commit review Review findings: expectDefined misused where absence is a legitimate state. CLI --profile/route-args missing next tokens take their existing miss paths; help normalization compares --help against the last positional again; first-time plugin install spreads absent cfg.plugins; denylist scan iterates manifest dependency entries instead of throwing on omitted sections; tailnet resolver returns a guaranteed string at the source instead of a caller-side undefined throw. * refactor(core): closed-key provider labels and honest optional passthroughs PROVIDER_LABELS becomes a satisfies-typed closed record (static reads provably defined; dynamic lookups go through providerUsageLabel with honest string|undefined). Status-scan overview passes its optional params through unchanged instead of asserting them. * fix(channels): make getChatChannelMeta honestly optional The original signature claimed ChatChannelMeta while leaking undefined on bundled channel id metadata drift; three of four callers already handled absence. The return type now says so, and the one assuming caller falls back to the raw channel label. * fix(core): index-safety for post-rebase main drift Covers the sqlite-sessions flip and auth-source-plan code that landed mid-phase, plus the channel-validation test consuming the now honestly optional getChatChannelMeta. * refactor(channels): split chat-meta accessors along the SDK contract getChatChannelMeta keeps its shipped plugin-SDK signature (defined for bundled ids, fail-loud on impossible misses); new findChatChannelMeta carries the drift-tolerant optional contract for core auto-enable and formatting paths. * fix(qa-channel): own channel metadata instead of a guaranteed-undefined catalog lookup qa-channel spread getChatChannelMeta over an id that is never in the bundled catalog, shipping an empty setup meta by accident; the fail-loud SDK accessor exposed it. The channel now declares its metadata once. * fix(gateway): heartbeat projection lookahead is optional at the transcript tail expectDefined wrapped messages[i + 1] whose absence on the final message is the normal case; the adjacent ternary already handled it. Restores the plain optional read with an explicit guard in the pair condition. * fix(plugin-sdk): channel plugin factory tolerates non-bundled channel ids again createChannelPluginBase spreads bundled catalog meta for ANY channel id, where absence is the normal case for external plugins; the resolver is honestly optional again while the exported bundled-id accessor keeps the fail-loud contract. * fix(core): spreads of optional config sections stay optional Fresh-setup and first-install paths (crestodian setup inference, hook installs, agent config base, target agent models) legitimately lack the section being rebuilt; spreading undefined is the shipped {} semantics. Removes the remaining gratuitous assertion wraps found by tree audit.
324 lines
10 KiB
TypeScript
324 lines
10 KiB
TypeScript
// Debug proxy runtime commands for capture sessions, validation, coverage, and blob reads.
|
|
import { spawn } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import process from "node:process";
|
|
import { expectDefined } from "@openclaw/normalization-core";
|
|
import { colorize, isRich, theme } from "../../packages/terminal-core/src/theme.js";
|
|
import { getRuntimeConfig } from "../config/config.js";
|
|
import {
|
|
runProxyValidation,
|
|
type ProxyValidationResult,
|
|
} from "../infra/net/proxy/proxy-validation.js";
|
|
import { ensureDebugProxyCa } from "../proxy-capture/ca.js";
|
|
import { buildDebugProxyCoverageReport } from "../proxy-capture/coverage.js";
|
|
import { resolveDebugProxySettings, applyDebugProxyEnv } from "../proxy-capture/env.js";
|
|
import { startDebugProxyServer } from "../proxy-capture/proxy-server.js";
|
|
import {
|
|
finalizeDebugProxyCapture,
|
|
initializeDebugProxyCapture,
|
|
} from "../proxy-capture/runtime.js";
|
|
import {
|
|
closeDebugProxyCaptureStore,
|
|
getDebugProxyCaptureStore,
|
|
} from "../proxy-capture/store.sqlite.js";
|
|
import type { CaptureQueryPreset } from "../proxy-capture/types.js";
|
|
|
|
export async function runDebugProxyStartCommand(opts: { host?: string; port?: number }) {
|
|
const settings = resolveDebugProxySettings();
|
|
const store = getDebugProxyCaptureStore();
|
|
store.upsertSession({
|
|
id: settings.sessionId,
|
|
startedAt: Date.now(),
|
|
mode: "proxy-start",
|
|
sourceScope: "openclaw",
|
|
sourceProcess: "openclaw",
|
|
proxyUrl: settings.proxyUrl,
|
|
});
|
|
initializeDebugProxyCapture("proxy-start", settings);
|
|
const ca = await ensureDebugProxyCa(settings.certDir);
|
|
const server = await startDebugProxyServer({
|
|
host: opts.host,
|
|
port: opts.port,
|
|
settings,
|
|
});
|
|
process.stdout.write(`Debug proxy: ${server.proxyUrl}\n`);
|
|
process.stdout.write(`CA cert: ${ca.certPath}\n`);
|
|
process.stdout.write(`Capture DB: ${store.dbPath}\n`);
|
|
process.stdout.write("Press Ctrl+C to stop.\n");
|
|
const shutdown = async () => {
|
|
process.off("SIGINT", onSignal);
|
|
process.off("SIGTERM", onSignal);
|
|
await server.stop();
|
|
if (settings.enabled) {
|
|
finalizeDebugProxyCapture(settings);
|
|
} else {
|
|
store.endSession(settings.sessionId);
|
|
closeDebugProxyCaptureStore();
|
|
}
|
|
process.exit(0);
|
|
};
|
|
const onSignal = () => {
|
|
void shutdown();
|
|
};
|
|
process.on("SIGINT", onSignal);
|
|
process.on("SIGTERM", onSignal);
|
|
await new Promise(() => {});
|
|
}
|
|
|
|
export async function runDebugProxyRunCommand(opts: {
|
|
host?: string;
|
|
port?: number;
|
|
commandArgs: string[];
|
|
}) {
|
|
// Each proxied child command gets its own capture session id for later query/filtering.
|
|
if (opts.commandArgs.length === 0) {
|
|
throw new Error("proxy run requires a command after --");
|
|
}
|
|
const sessionId = randomUUID();
|
|
const baseSettings = resolveDebugProxySettings();
|
|
const settings = {
|
|
...baseSettings,
|
|
sessionId,
|
|
};
|
|
getDebugProxyCaptureStore().upsertSession({
|
|
id: sessionId,
|
|
startedAt: Date.now(),
|
|
mode: "proxy-run",
|
|
sourceScope: "openclaw",
|
|
sourceProcess: "openclaw",
|
|
proxyUrl: undefined,
|
|
});
|
|
const server = await startDebugProxyServer({
|
|
host: opts.host,
|
|
port: opts.port,
|
|
settings,
|
|
});
|
|
const [command, ...args] = opts.commandArgs;
|
|
const childEnv = applyDebugProxyEnv(process.env, {
|
|
proxyUrl: server.proxyUrl,
|
|
sessionId,
|
|
certDir: settings.certDir,
|
|
});
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const child = spawn(expectDefined(command, "proxy cli.runtime command"), args, {
|
|
stdio: "inherit",
|
|
env: childEnv,
|
|
cwd: process.cwd(),
|
|
});
|
|
child.once("error", reject);
|
|
child.once("exit", (code, signal) => {
|
|
process.exitCode = signal ? 1 : (code ?? 1);
|
|
resolve();
|
|
});
|
|
});
|
|
} finally {
|
|
await server.stop();
|
|
getDebugProxyCaptureStore().endSession(sessionId);
|
|
}
|
|
}
|
|
|
|
function redactProxyUrl(value: string | undefined): string | undefined {
|
|
if (!value) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
const url = new URL(value);
|
|
if (url.username || url.password) {
|
|
url.username = "redacted";
|
|
url.password = "redacted";
|
|
}
|
|
url.search = "";
|
|
url.hash = "";
|
|
return url.toString();
|
|
} catch {
|
|
return "<invalid proxy URL>";
|
|
}
|
|
}
|
|
|
|
function redactProxyValidationResult(result: ProxyValidationResult): ProxyValidationResult {
|
|
return {
|
|
...result,
|
|
config: {
|
|
...result.config,
|
|
proxyUrl: redactProxyUrl(result.config.proxyUrl),
|
|
},
|
|
};
|
|
}
|
|
|
|
type ProxyValidationTextColors = {
|
|
heading: (value: string) => string;
|
|
success: (value: string) => string;
|
|
error: (value: string) => string;
|
|
muted: (value: string) => string;
|
|
warn: (value: string) => string;
|
|
};
|
|
|
|
function getProxyValidationTextColors(): ProxyValidationTextColors {
|
|
const rich = isRich();
|
|
const apply = (color: (value: string) => string) => (value: string) =>
|
|
colorize(rich, color, value);
|
|
return {
|
|
heading: apply(theme.heading),
|
|
success: apply(theme.success),
|
|
error: apply(theme.error),
|
|
muted: apply(theme.muted),
|
|
warn: apply(theme.warn),
|
|
};
|
|
}
|
|
|
|
function formatProxyCheckLine(
|
|
check: ProxyValidationResult["checks"][number],
|
|
colors: ProxyValidationTextColors,
|
|
): string {
|
|
const icon = check.ok ? colors.success("✓") : colors.error("✗");
|
|
const paddedKind = colors.muted(check.kind.padEnd(7, " "));
|
|
const status =
|
|
check.status === undefined
|
|
? ""
|
|
: ` ${check.ok ? colors.success(`HTTP ${check.status}`) : colors.error(`HTTP ${check.status}`)}`;
|
|
const detail = check.error
|
|
? ` — ${check.ok ? colors.muted(check.error) : colors.error(check.error)}`
|
|
: "";
|
|
return ` ${icon} ${paddedKind} ${check.url}${status}${detail}`;
|
|
}
|
|
|
|
function formatProxyValidationNextSteps(result: ProxyValidationResult): string[] {
|
|
if (result.ok) {
|
|
return [];
|
|
}
|
|
if (result.config.errors.some((error) => error.includes("proxy.enabled"))) {
|
|
return [
|
|
"Enable proxy.enabled with proxy.proxyUrl or OPENCLAW_PROXY_URL, or pass --proxy-url for an explicit one-off validation.",
|
|
];
|
|
}
|
|
if (result.config.errors.some((error) => error.includes("proxy CA file could not be read"))) {
|
|
return [
|
|
"Confirm proxy.tls.caFile or --proxy-ca-file points to a readable PEM CA file for the HTTPS proxy endpoint.",
|
|
];
|
|
}
|
|
if (result.config.errors.length > 0) {
|
|
return [
|
|
"Fix proxy.proxyUrl, OPENCLAW_PROXY_URL, or --proxy-url so it uses a reachable http:// or https:// proxy.",
|
|
];
|
|
}
|
|
if (result.checks.some((check) => !check.ok && check.kind === "allowed")) {
|
|
return [
|
|
"Confirm the proxy is reachable from this deployment context and permits the allowed destinations.",
|
|
];
|
|
}
|
|
if (result.checks.some((check) => !check.ok && check.kind === "denied")) {
|
|
return [
|
|
"Update the proxy ACL so denied destinations are blocked, or pass the expected --denied-url values.",
|
|
];
|
|
}
|
|
return [
|
|
"Review the failed checks above and update proxy configuration or validation destinations.",
|
|
];
|
|
}
|
|
|
|
function formatProxyValidationText(result: ProxyValidationResult): string {
|
|
const colors = getProxyValidationTextColors();
|
|
const redactedProxyUrl = redactProxyUrl(result.config.proxyUrl);
|
|
const lines = [
|
|
result.ok ? colors.success("Proxy validation passed") : colors.error("Proxy validation failed"),
|
|
"",
|
|
colors.heading("Proxy"),
|
|
` Source: ${colors.muted(result.config.source)}`,
|
|
` URL: ${redactedProxyUrl ?? colors.muted("not configured")}`,
|
|
];
|
|
|
|
if (result.config.errors.length > 0) {
|
|
lines.push("", colors.heading("Problems"));
|
|
for (const error of result.config.errors) {
|
|
lines.push(` - ${colors.error(error)}`);
|
|
}
|
|
}
|
|
|
|
if (result.checks.length > 0) {
|
|
lines.push("", colors.heading("Checks"));
|
|
for (const check of result.checks) {
|
|
lines.push(formatProxyCheckLine(check, colors));
|
|
}
|
|
}
|
|
|
|
const nextSteps = formatProxyValidationNextSteps(result);
|
|
if (nextSteps.length > 0) {
|
|
lines.push("", colors.heading("Next steps"));
|
|
for (const nextStep of nextSteps) {
|
|
lines.push(` ${colors.warn(nextStep)}`);
|
|
}
|
|
}
|
|
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
export async function runProxyValidateCommand(opts: {
|
|
json?: boolean;
|
|
proxyUrl?: string;
|
|
proxyCaFile?: string;
|
|
allowedUrls?: string[];
|
|
deniedUrls?: string[];
|
|
apnsReachability?: boolean;
|
|
apnsAuthority?: string;
|
|
timeoutMs?: number;
|
|
}) {
|
|
const config = getRuntimeConfig();
|
|
const result = await runProxyValidation({
|
|
config: config?.proxy,
|
|
env: process.env,
|
|
proxyUrlOverride: opts.proxyUrl,
|
|
proxyCaFileOverride: opts.proxyCaFile,
|
|
allowedUrls: opts.allowedUrls,
|
|
deniedUrls: opts.deniedUrls,
|
|
apnsReachability: opts.apnsReachability,
|
|
apnsAuthority: opts.apnsAuthority,
|
|
timeoutMs: opts.timeoutMs,
|
|
});
|
|
const outputResult = redactProxyValidationResult(result);
|
|
process.stdout.write(
|
|
opts.json === true
|
|
? `${JSON.stringify(outputResult, null, 2)}\n`
|
|
: formatProxyValidationText(outputResult),
|
|
);
|
|
if (!result.ok) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
export async function runDebugProxySessionsCommand(opts: { limit?: number }) {
|
|
const sessions = getDebugProxyCaptureStore().listSessions(opts.limit ?? 20);
|
|
process.stdout.write(`${JSON.stringify(sessions, null, 2)}\n`);
|
|
closeDebugProxyCaptureStore();
|
|
}
|
|
|
|
export async function runDebugProxyQueryCommand(opts: {
|
|
preset: CaptureQueryPreset;
|
|
sessionId?: string;
|
|
}) {
|
|
const rows = getDebugProxyCaptureStore().queryPreset(opts.preset, opts.sessionId);
|
|
process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
|
|
closeDebugProxyCaptureStore();
|
|
}
|
|
|
|
export async function runDebugProxyCoverageCommand() {
|
|
process.stdout.write(`${JSON.stringify(buildDebugProxyCoverageReport(), null, 2)}\n`);
|
|
closeDebugProxyCaptureStore();
|
|
}
|
|
|
|
export async function runDebugProxyPurgeCommand() {
|
|
const result = getDebugProxyCaptureStore().purgeAll();
|
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
closeDebugProxyCaptureStore();
|
|
}
|
|
|
|
export async function readDebugProxyBlobCommand(opts: { blobId: string }) {
|
|
const content = getDebugProxyCaptureStore().readBlob(opts.blobId);
|
|
if (content == null) {
|
|
closeDebugProxyCaptureStore();
|
|
throw new Error(`Unknown blob: ${opts.blobId}`);
|
|
}
|
|
process.stdout.write(content);
|
|
closeDebugProxyCaptureStore();
|
|
}
|