Merge branch 'main' into commands-list-clean
This commit is contained in:
67
src/agents/auth-health.test.ts
Normal file
67
src/agents/auth-health.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildAuthHealthSummary,
|
||||
DEFAULT_OAUTH_WARN_MS,
|
||||
} from "./auth-health.js";
|
||||
|
||||
describe("buildAuthHealthSummary", () => {
|
||||
const now = 1_700_000_000_000;
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("classifies OAuth and API key profiles", () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
const store = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:ok": {
|
||||
type: "oauth" as const,
|
||||
provider: "anthropic",
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: now + DEFAULT_OAUTH_WARN_MS + 60_000,
|
||||
},
|
||||
"anthropic:expiring": {
|
||||
type: "oauth" as const,
|
||||
provider: "anthropic",
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: now + 10_000,
|
||||
},
|
||||
"anthropic:expired": {
|
||||
type: "oauth" as const,
|
||||
provider: "anthropic",
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: now - 10_000,
|
||||
},
|
||||
"anthropic:api": {
|
||||
type: "api_key" as const,
|
||||
provider: "anthropic",
|
||||
key: "sk-ant-api",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const summary = buildAuthHealthSummary({
|
||||
store,
|
||||
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
|
||||
});
|
||||
|
||||
const statuses = Object.fromEntries(
|
||||
summary.profiles.map((profile) => [profile.profileId, profile.status]),
|
||||
);
|
||||
|
||||
expect(statuses["anthropic:ok"]).toBe("ok");
|
||||
expect(statuses["anthropic:expiring"]).toBe("expiring");
|
||||
expect(statuses["anthropic:expired"]).toBe("expired");
|
||||
expect(statuses["anthropic:api"]).toBe("static");
|
||||
|
||||
const provider = summary.providers.find(
|
||||
(entry) => entry.provider === "anthropic",
|
||||
);
|
||||
expect(provider?.status).toBe("expired");
|
||||
});
|
||||
});
|
||||
227
src/agents/auth-health.ts
Normal file
227
src/agents/auth-health.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
type AuthProfileCredential,
|
||||
type AuthProfileStore,
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
CODEX_CLI_PROFILE_ID,
|
||||
resolveAuthProfileDisplayLabel,
|
||||
} from "./auth-profiles.js";
|
||||
|
||||
export type AuthProfileSource = "claude-cli" | "codex-cli" | "store";
|
||||
|
||||
export type AuthProfileHealthStatus =
|
||||
| "ok"
|
||||
| "expiring"
|
||||
| "expired"
|
||||
| "missing"
|
||||
| "static";
|
||||
|
||||
export type AuthProfileHealth = {
|
||||
profileId: string;
|
||||
provider: string;
|
||||
type: "oauth" | "api_key";
|
||||
status: AuthProfileHealthStatus;
|
||||
expiresAt?: number;
|
||||
remainingMs?: number;
|
||||
source: AuthProfileSource;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type AuthProviderHealthStatus =
|
||||
| "ok"
|
||||
| "expiring"
|
||||
| "expired"
|
||||
| "missing"
|
||||
| "static";
|
||||
|
||||
export type AuthProviderHealth = {
|
||||
provider: string;
|
||||
status: AuthProviderHealthStatus;
|
||||
expiresAt?: number;
|
||||
remainingMs?: number;
|
||||
profiles: AuthProfileHealth[];
|
||||
};
|
||||
|
||||
export type AuthHealthSummary = {
|
||||
now: number;
|
||||
warnAfterMs: number;
|
||||
profiles: AuthProfileHealth[];
|
||||
providers: AuthProviderHealth[];
|
||||
};
|
||||
|
||||
export const DEFAULT_OAUTH_WARN_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function resolveAuthProfileSource(profileId: string): AuthProfileSource {
|
||||
if (profileId === CLAUDE_CLI_PROFILE_ID) return "claude-cli";
|
||||
if (profileId === CODEX_CLI_PROFILE_ID) return "codex-cli";
|
||||
return "store";
|
||||
}
|
||||
|
||||
export function formatRemainingShort(remainingMs?: number): string {
|
||||
if (remainingMs === undefined || Number.isNaN(remainingMs)) return "unknown";
|
||||
if (remainingMs <= 0) return "0m";
|
||||
const minutes = Math.max(1, Math.round(remainingMs / 60_000));
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 48) return `${hours}h`;
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
function resolveOAuthStatus(
|
||||
expiresAt: number | undefined,
|
||||
now: number,
|
||||
warnAfterMs: number,
|
||||
): { status: AuthProfileHealthStatus; remainingMs?: number } {
|
||||
if (!expiresAt || !Number.isFinite(expiresAt) || expiresAt <= 0) {
|
||||
return { status: "missing" };
|
||||
}
|
||||
const remainingMs = expiresAt - now;
|
||||
if (remainingMs <= 0) {
|
||||
return { status: "expired", remainingMs };
|
||||
}
|
||||
if (remainingMs <= warnAfterMs) {
|
||||
return { status: "expiring", remainingMs };
|
||||
}
|
||||
return { status: "ok", remainingMs };
|
||||
}
|
||||
|
||||
function buildProfileHealth(params: {
|
||||
profileId: string;
|
||||
credential: AuthProfileCredential;
|
||||
store: AuthProfileStore;
|
||||
cfg?: ClawdbotConfig;
|
||||
now: number;
|
||||
warnAfterMs: number;
|
||||
}): AuthProfileHealth {
|
||||
const { profileId, credential, store, cfg, now, warnAfterMs } = params;
|
||||
const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
|
||||
const source = resolveAuthProfileSource(profileId);
|
||||
|
||||
if (credential.type === "api_key") {
|
||||
return {
|
||||
profileId,
|
||||
provider: credential.provider,
|
||||
type: "api_key",
|
||||
status: "static",
|
||||
source,
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
const { status, remainingMs } = resolveOAuthStatus(
|
||||
credential.expires,
|
||||
now,
|
||||
warnAfterMs,
|
||||
);
|
||||
return {
|
||||
profileId,
|
||||
provider: credential.provider,
|
||||
type: "oauth",
|
||||
status,
|
||||
expiresAt: credential.expires,
|
||||
remainingMs,
|
||||
source,
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAuthHealthSummary(params: {
|
||||
store: AuthProfileStore;
|
||||
cfg?: ClawdbotConfig;
|
||||
warnAfterMs?: number;
|
||||
providers?: string[];
|
||||
}): AuthHealthSummary {
|
||||
const now = Date.now();
|
||||
const warnAfterMs = params.warnAfterMs ?? DEFAULT_OAUTH_WARN_MS;
|
||||
const providerFilter = params.providers
|
||||
? new Set(params.providers.map((p) => p.trim()).filter(Boolean))
|
||||
: null;
|
||||
|
||||
const profiles = Object.entries(params.store.profiles)
|
||||
.filter(([_, cred]) =>
|
||||
providerFilter ? providerFilter.has(cred.provider) : true,
|
||||
)
|
||||
.map(([profileId, credential]) =>
|
||||
buildProfileHealth({
|
||||
profileId,
|
||||
credential,
|
||||
store: params.store,
|
||||
cfg: params.cfg,
|
||||
now,
|
||||
warnAfterMs,
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.provider !== b.provider) {
|
||||
return a.provider.localeCompare(b.provider);
|
||||
}
|
||||
return a.profileId.localeCompare(b.profileId);
|
||||
});
|
||||
|
||||
const providersMap = new Map<string, AuthProviderHealth>();
|
||||
for (const profile of profiles) {
|
||||
const existing = providersMap.get(profile.provider);
|
||||
if (!existing) {
|
||||
providersMap.set(profile.provider, {
|
||||
provider: profile.provider,
|
||||
status: "missing",
|
||||
profiles: [profile],
|
||||
});
|
||||
} else {
|
||||
existing.profiles.push(profile);
|
||||
}
|
||||
}
|
||||
|
||||
if (providerFilter) {
|
||||
for (const provider of providerFilter) {
|
||||
if (!providersMap.has(provider)) {
|
||||
providersMap.set(provider, {
|
||||
provider,
|
||||
status: "missing",
|
||||
profiles: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const provider of providersMap.values()) {
|
||||
if (provider.profiles.length === 0) {
|
||||
provider.status = "missing";
|
||||
continue;
|
||||
}
|
||||
|
||||
const oauthProfiles = provider.profiles.filter((p) => p.type === "oauth");
|
||||
const apiKeyProfiles = provider.profiles.filter(
|
||||
(p) => p.type === "api_key",
|
||||
);
|
||||
|
||||
if (oauthProfiles.length === 0) {
|
||||
provider.status = apiKeyProfiles.length > 0 ? "static" : "missing";
|
||||
continue;
|
||||
}
|
||||
|
||||
const expiryCandidates = oauthProfiles
|
||||
.map((p) => p.expiresAt)
|
||||
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
|
||||
if (expiryCandidates.length > 0) {
|
||||
provider.expiresAt = Math.min(...expiryCandidates);
|
||||
provider.remainingMs = provider.expiresAt - now;
|
||||
}
|
||||
|
||||
const statuses = oauthProfiles.map((p) => p.status);
|
||||
if (statuses.includes("expired") || statuses.includes("missing")) {
|
||||
provider.status = "expired";
|
||||
} else if (statuses.includes("expiring")) {
|
||||
provider.status = "expiring";
|
||||
} else {
|
||||
provider.status = "ok";
|
||||
}
|
||||
}
|
||||
|
||||
const providers = Array.from(providersMap.values()).sort((a, b) =>
|
||||
a.provider.localeCompare(b.provider),
|
||||
);
|
||||
|
||||
return { now, warnAfterMs, profiles, providers };
|
||||
}
|
||||
@@ -159,6 +159,9 @@ describe("directive behavior", () => {
|
||||
expect(text).toContain(
|
||||
"Current queue settings: mode=collect, debounce=1500ms, cap=9, drop=summarize.",
|
||||
);
|
||||
expect(text).toContain(
|
||||
"Options: modes steer, followup, collect, steer+backlog, interrupt; debounce:<ms|s|m>, cap:<n>, drop:old|new|summarize.",
|
||||
);
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -182,6 +185,7 @@ describe("directive behavior", () => {
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Current thinking level: high");
|
||||
expect(text).toContain("Options: off, minimal, low, medium, high.");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -204,6 +208,7 @@ describe("directive behavior", () => {
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Current thinking level: off");
|
||||
expect(text).toContain("Options: off, minimal, low, medium, high.");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -358,6 +363,7 @@ describe("directive behavior", () => {
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Current thinking level: high");
|
||||
expect(text).toContain("Options: off, minimal, low, medium, high.");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -380,6 +386,7 @@ describe("directive behavior", () => {
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Current thinking level: off");
|
||||
expect(text).toContain("Options: off, minimal, low, medium, high.");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -403,6 +410,7 @@ describe("directive behavior", () => {
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Current verbose level: on");
|
||||
expect(text).toContain("Options: on, off.");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -425,6 +433,7 @@ describe("directive behavior", () => {
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Current reasoning level: off");
|
||||
expect(text).toContain("Options: on, off, stream.");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -458,6 +467,7 @@ describe("directive behavior", () => {
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Current elevated level: on");
|
||||
expect(text).toContain("Options: on, off.");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +54,9 @@ import {
|
||||
} from "./queue.js";
|
||||
|
||||
const SYSTEM_MARK = "⚙️";
|
||||
const formatOptionsLine = (options: string) => `Options: ${options}.`;
|
||||
const withOptions = (line: string, options: string) =>
|
||||
`${line}\n${formatOptionsLine(options)}`;
|
||||
|
||||
const maskApiKey = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
@@ -417,7 +420,12 @@ export async function handleDirectiveOnly(params: {
|
||||
// If no argument was provided, show the current level
|
||||
if (!directives.rawThinkLevel) {
|
||||
const level = currentThinkLevel ?? "off";
|
||||
return { text: `Current thinking level: ${level}.` };
|
||||
return {
|
||||
text: withOptions(
|
||||
`Current thinking level: ${level}.`,
|
||||
"off, minimal, low, medium, high",
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: `Unrecognized thinking level "${directives.rawThinkLevel}". Valid levels: off, minimal, low, medium, high.`,
|
||||
@@ -426,7 +434,9 @@ export async function handleDirectiveOnly(params: {
|
||||
if (directives.hasVerboseDirective && !directives.verboseLevel) {
|
||||
if (!directives.rawVerboseLevel) {
|
||||
const level = currentVerboseLevel ?? "off";
|
||||
return { text: `Current verbose level: ${level}.` };
|
||||
return {
|
||||
text: withOptions(`Current verbose level: ${level}.`, "on, off"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: `Unrecognized verbose level "${directives.rawVerboseLevel}". Valid levels: off, on.`,
|
||||
@@ -435,7 +445,12 @@ export async function handleDirectiveOnly(params: {
|
||||
if (directives.hasReasoningDirective && !directives.reasoningLevel) {
|
||||
if (!directives.rawReasoningLevel) {
|
||||
const level = currentReasoningLevel ?? "off";
|
||||
return { text: `Current reasoning level: ${level}.` };
|
||||
return {
|
||||
text: withOptions(
|
||||
`Current reasoning level: ${level}.`,
|
||||
"on, off, stream",
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: `Unrecognized reasoning level "${directives.rawReasoningLevel}". Valid levels: on, off, stream.`,
|
||||
@@ -447,7 +462,9 @@ export async function handleDirectiveOnly(params: {
|
||||
return { text: "elevated is not available right now." };
|
||||
}
|
||||
const level = currentElevatedLevel ?? "off";
|
||||
return { text: `Current elevated level: ${level}.` };
|
||||
return {
|
||||
text: withOptions(`Current elevated level: ${level}.`, "on, off"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on.`,
|
||||
@@ -483,7 +500,10 @@ export async function handleDirectiveOnly(params: {
|
||||
typeof settings.cap === "number" ? String(settings.cap) : "default";
|
||||
const dropLabel = settings.dropPolicy ?? "default";
|
||||
return {
|
||||
text: `Current queue settings: mode=${settings.mode}, debounce=${debounceLabel}, cap=${capLabel}, drop=${dropLabel}.`,
|
||||
text: withOptions(
|
||||
`Current queue settings: mode=${settings.mode}, debounce=${debounceLabel}, cap=${capLabel}, drop=${dropLabel}.`,
|
||||
"modes steer, followup, collect, steer+backlog, interrupt; debounce:<ms|s|m>, cap:<n>, drop:old|new|summarize",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -90,8 +90,10 @@ describe("gateway SIGTERM", () => {
|
||||
const err: string[] = [];
|
||||
|
||||
child = spawn(
|
||||
"bun",
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
"src/index.ts",
|
||||
"gateway",
|
||||
"--port",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import type { Command } from "commander";
|
||||
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
||||
import { parseLogLine } from "../logging/parse-log-line.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js";
|
||||
|
||||
type LogsTailPayload = {
|
||||
@@ -18,6 +21,8 @@ type LogsCliOptions = {
|
||||
follow?: boolean;
|
||||
interval?: string;
|
||||
json?: boolean;
|
||||
plain?: boolean;
|
||||
color?: boolean;
|
||||
url?: string;
|
||||
token?: string;
|
||||
timeout?: string;
|
||||
@@ -47,6 +52,92 @@ async function fetchLogs(
|
||||
return payload as LogsTailPayload;
|
||||
}
|
||||
|
||||
function formatLogTimestamp(value?: string, mode: "pretty" | "plain" = "plain") {
|
||||
if (!value) return "";
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
if (mode === "pretty") return parsed.toISOString().slice(11, 19);
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
function formatLogLine(
|
||||
raw: string,
|
||||
opts: {
|
||||
pretty: boolean;
|
||||
rich: boolean;
|
||||
},
|
||||
): string {
|
||||
const parsed = parseLogLine(raw);
|
||||
if (!parsed) return raw;
|
||||
const label = parsed.subsystem ?? parsed.module ?? "";
|
||||
const time = formatLogTimestamp(parsed.time, opts.pretty ? "pretty" : "plain");
|
||||
const level = parsed.level ?? "";
|
||||
const levelLabel = level.padEnd(5).trim();
|
||||
const message = parsed.message || parsed.raw;
|
||||
|
||||
if (!opts.pretty) {
|
||||
return [time, level, label, message].filter(Boolean).join(" ").trim();
|
||||
}
|
||||
|
||||
const timeLabel = colorize(opts.rich, theme.muted, time);
|
||||
const labelValue = colorize(opts.rich, theme.accent, label);
|
||||
const levelValue =
|
||||
level === "error" || level === "fatal"
|
||||
? colorize(opts.rich, theme.error, levelLabel)
|
||||
: level === "warn"
|
||||
? colorize(opts.rich, theme.warn, levelLabel)
|
||||
: level === "debug" || level === "trace"
|
||||
? colorize(opts.rich, theme.muted, levelLabel)
|
||||
: colorize(opts.rich, theme.info, levelLabel);
|
||||
const messageValue =
|
||||
level === "error" || level === "fatal"
|
||||
? colorize(opts.rich, theme.error, message)
|
||||
: level === "warn"
|
||||
? colorize(opts.rich, theme.warn, message)
|
||||
: level === "debug" || level === "trace"
|
||||
? colorize(opts.rich, theme.muted, message)
|
||||
: colorize(opts.rich, theme.info, message);
|
||||
|
||||
const head = [timeLabel, levelValue, labelValue].filter(Boolean).join(" ");
|
||||
return [head, messageValue].filter(Boolean).join(" ").trim();
|
||||
}
|
||||
|
||||
function emitJsonLine(payload: Record<string, unknown>, toStdErr = false) {
|
||||
const text = `${JSON.stringify(payload)}\n`;
|
||||
if (toStdErr) process.stderr.write(text);
|
||||
else process.stdout.write(text);
|
||||
}
|
||||
|
||||
function emitGatewayError(
|
||||
err: unknown,
|
||||
opts: LogsCliOptions,
|
||||
mode: "json" | "text",
|
||||
rich: boolean,
|
||||
) {
|
||||
const details = buildGatewayConnectionDetails({ url: opts.url });
|
||||
const message = "Gateway not reachable. Is it running and accessible?";
|
||||
const hint = "Hint: run `clawdbot doctor`.";
|
||||
const errorText = err instanceof Error ? err.message : String(err);
|
||||
|
||||
if (mode === "json") {
|
||||
emitJsonLine(
|
||||
{
|
||||
type: "error",
|
||||
message,
|
||||
error: errorText,
|
||||
details,
|
||||
hint,
|
||||
},
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
defaultRuntime.error(colorize(rich, theme.error, message));
|
||||
defaultRuntime.error(details.message);
|
||||
defaultRuntime.error(colorize(rich, theme.muted, hint));
|
||||
}
|
||||
|
||||
export function registerLogsCli(program: Command) {
|
||||
const logs = program
|
||||
.command("logs")
|
||||
@@ -55,7 +146,9 @@ export function registerLogsCli(program: Command) {
|
||||
.option("--max-bytes <n>", "Max bytes to read", "250000")
|
||||
.option("--follow", "Follow log output", false)
|
||||
.option("--interval <ms>", "Polling interval in ms", "1000")
|
||||
.option("--json", "Emit JSON payloads", false);
|
||||
.option("--json", "Emit JSON log lines", false)
|
||||
.option("--plain", "Plain text output (no ANSI styling)", false)
|
||||
.option("--no-color", "Disable ANSI colors");
|
||||
|
||||
addGatewayClientOptions(logs);
|
||||
|
||||
@@ -63,18 +156,63 @@ export function registerLogsCli(program: Command) {
|
||||
const interval = parsePositiveInt(opts.interval, 1000);
|
||||
let cursor: number | undefined;
|
||||
let first = true;
|
||||
const jsonMode = Boolean(opts.json);
|
||||
const pretty = !jsonMode && Boolean(process.stdout.isTTY) && !opts.plain;
|
||||
const rich = isRich() && opts.color !== false;
|
||||
|
||||
while (true) {
|
||||
const payload = await fetchLogs(opts, cursor);
|
||||
let payload: LogsTailPayload;
|
||||
try {
|
||||
payload = await fetchLogs(opts, cursor);
|
||||
} catch (err) {
|
||||
emitGatewayError(err, opts, jsonMode ? "json" : "text", rich);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const lines = Array.isArray(payload.lines) ? payload.lines : [];
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(payload, null, 2));
|
||||
} else {
|
||||
if (first && payload.file) {
|
||||
defaultRuntime.log(`Log file: ${payload.file}`);
|
||||
if (jsonMode) {
|
||||
if (first) {
|
||||
emitJsonLine({
|
||||
type: "meta",
|
||||
file: payload.file,
|
||||
cursor: payload.cursor,
|
||||
size: payload.size,
|
||||
});
|
||||
}
|
||||
for (const line of lines) {
|
||||
defaultRuntime.log(line);
|
||||
const parsed = parseLogLine(line);
|
||||
if (parsed) {
|
||||
emitJsonLine({ type: "log", ...parsed });
|
||||
} else {
|
||||
emitJsonLine({ type: "raw", raw: line });
|
||||
}
|
||||
}
|
||||
if (payload.truncated) {
|
||||
emitJsonLine({
|
||||
type: "notice",
|
||||
message: "Log tail truncated (increase --max-bytes).",
|
||||
});
|
||||
}
|
||||
if (payload.reset) {
|
||||
emitJsonLine({
|
||||
type: "notice",
|
||||
message: "Log cursor reset (file rotated).",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (first && payload.file) {
|
||||
const prefix = pretty
|
||||
? colorize(rich, theme.muted, "Log file:")
|
||||
: "Log file:";
|
||||
defaultRuntime.log(`${prefix} ${payload.file}`);
|
||||
}
|
||||
for (const line of lines) {
|
||||
defaultRuntime.log(
|
||||
formatLogLine(line, {
|
||||
pretty,
|
||||
rich,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (payload.truncated) {
|
||||
defaultRuntime.error("Log tail truncated (increase --max-bytes).");
|
||||
|
||||
@@ -57,6 +57,11 @@ export function registerModelsCli(program: Command) {
|
||||
.description("Show configured model state")
|
||||
.option("--json", "Output JSON", false)
|
||||
.option("--plain", "Plain output", false)
|
||||
.option(
|
||||
"--check",
|
||||
"Exit non-zero if auth is expiring/expired (1=expired/missing, 2=expiring)",
|
||||
false,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await modelsStatusCommand(opts, defaultRuntime);
|
||||
|
||||
@@ -232,9 +232,10 @@ export function buildProgram() {
|
||||
.option("--mode <mode>", "Wizard mode: local|remote")
|
||||
.option(
|
||||
"--auth-choice <choice>",
|
||||
"Auth: oauth|claude-cli|openai-codex|codex-cli|antigravity|apiKey|minimax|skip",
|
||||
"Auth: oauth|claude-cli|openai-codex|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip",
|
||||
)
|
||||
.option("--anthropic-api-key <key>", "Anthropic API key")
|
||||
.option("--gemini-api-key <key>", "Gemini API key")
|
||||
.option("--gateway-port <port>", "Gateway port")
|
||||
.option("--gateway-bind <mode>", "Gateway bind: loopback|lan|tailnet|auto")
|
||||
.option("--gateway-auth <mode>", "Gateway auth: off|token|password")
|
||||
@@ -263,11 +264,13 @@ export function buildProgram() {
|
||||
| "openai-codex"
|
||||
| "codex-cli"
|
||||
| "antigravity"
|
||||
| "gemini-api-key"
|
||||
| "apiKey"
|
||||
| "minimax"
|
||||
| "skip"
|
||||
| undefined,
|
||||
anthropicApiKey: opts.anthropicApiKey as string | undefined,
|
||||
geminiApiKey: opts.geminiApiKey as string | undefined,
|
||||
gatewayPort:
|
||||
typeof opts.gatewayPort === "string"
|
||||
? Number.parseInt(opts.gatewayPort, 10)
|
||||
|
||||
@@ -85,6 +85,7 @@ export function buildAuthChoiceOptions(params: {
|
||||
value: "antigravity",
|
||||
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
|
||||
});
|
||||
options.push({ value: "gemini-api-key", label: "Google Gemini API key" });
|
||||
options.push({ value: "apiKey", label: "Anthropic API key" });
|
||||
options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" });
|
||||
if (params.includeSkip) {
|
||||
|
||||
@@ -25,11 +25,16 @@ import {
|
||||
isRemoteEnvironment,
|
||||
loginAntigravityVpsAware,
|
||||
} from "./antigravity-oauth.js";
|
||||
import {
|
||||
applyGoogleGeminiModelDefault,
|
||||
GOOGLE_GEMINI_DEFAULT_MODEL,
|
||||
} from "./google-gemini-model-default.js";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
applyMinimaxConfig,
|
||||
applyMinimaxProviderConfig,
|
||||
setAnthropicApiKey,
|
||||
setGeminiApiKey,
|
||||
writeOAuthCredentials,
|
||||
} from "./onboard-auth.js";
|
||||
import { openUrl } from "./onboard-helpers.js";
|
||||
@@ -415,6 +420,30 @@ export async function applyAuthChoice(params: {
|
||||
"OAuth help",
|
||||
);
|
||||
}
|
||||
} else if (params.authChoice === "gemini-api-key") {
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter Gemini API key",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
});
|
||||
await setGeminiApiKey(String(key).trim(), params.agentDir);
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "google:default",
|
||||
provider: "google",
|
||||
mode: "api_key",
|
||||
});
|
||||
if (params.setDefaultModel) {
|
||||
const applied = applyGoogleGeminiModelDefault(nextConfig);
|
||||
nextConfig = applied.next;
|
||||
if (applied.changed) {
|
||||
await params.prompter.note(
|
||||
`Default model set to ${GOOGLE_GEMINI_DEFAULT_MODEL}`,
|
||||
"Model configured",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
agentModelOverride = GOOGLE_GEMINI_DEFAULT_MODEL;
|
||||
await noteAgentModel(GOOGLE_GEMINI_DEFAULT_MODEL);
|
||||
}
|
||||
} else if (params.authChoice === "apiKey") {
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter Anthropic API key",
|
||||
|
||||
@@ -50,11 +50,16 @@ import {
|
||||
GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||
type GatewayDaemonRuntime,
|
||||
} from "./daemon-runtime.js";
|
||||
import {
|
||||
applyGoogleGeminiModelDefault,
|
||||
GOOGLE_GEMINI_DEFAULT_MODEL,
|
||||
} from "./google-gemini-model-default.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
applyMinimaxConfig,
|
||||
setAnthropicApiKey,
|
||||
setGeminiApiKey,
|
||||
writeOAuthCredentials,
|
||||
} from "./onboard-auth.js";
|
||||
import {
|
||||
@@ -300,6 +305,7 @@ async function promptAuthConfig(
|
||||
| "openai-codex"
|
||||
| "codex-cli"
|
||||
| "antigravity"
|
||||
| "gemini-api-key"
|
||||
| "apiKey"
|
||||
| "minimax"
|
||||
| "skip";
|
||||
@@ -513,6 +519,28 @@ async function promptAuthConfig(
|
||||
runtime.error(String(err));
|
||||
note("Trouble with OAuth? See https://docs.clawd.bot/start/faq", "OAuth");
|
||||
}
|
||||
} else if (authChoice === "gemini-api-key") {
|
||||
const key = guardCancel(
|
||||
await text({
|
||||
message: "Enter Gemini API key",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
await setGeminiApiKey(String(key).trim());
|
||||
next = applyAuthProfileConfig(next, {
|
||||
profileId: "google:default",
|
||||
provider: "google",
|
||||
mode: "api_key",
|
||||
});
|
||||
const applied = applyGoogleGeminiModelDefault(next);
|
||||
next = applied.next;
|
||||
if (applied.changed) {
|
||||
note(
|
||||
`Default model set to ${GOOGLE_GEMINI_DEFAULT_MODEL}`,
|
||||
"Model configured",
|
||||
);
|
||||
}
|
||||
} else if (authChoice === "apiKey") {
|
||||
const key = guardCancel(
|
||||
await text({
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { note } from "@clack/prompts";
|
||||
|
||||
import {
|
||||
buildAuthHealthSummary,
|
||||
DEFAULT_OAUTH_WARN_MS,
|
||||
formatRemainingShort,
|
||||
} from "../agents/auth-health.js";
|
||||
import {
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
CODEX_CLI_PROFILE_ID,
|
||||
ensureAuthProfileStore,
|
||||
repairOAuthProfileIdMismatch,
|
||||
resolveApiKeyForProfile,
|
||||
} from "../agents/auth-profiles.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
@@ -28,3 +36,114 @@ export async function maybeRepairAnthropicOAuthProfileId(
|
||||
if (!apply) return cfg;
|
||||
return repair.config;
|
||||
}
|
||||
|
||||
type AuthIssue = {
|
||||
profileId: string;
|
||||
provider: string;
|
||||
status: string;
|
||||
remainingMs?: number;
|
||||
};
|
||||
|
||||
function formatAuthIssueHint(issue: AuthIssue): string | null {
|
||||
if (
|
||||
issue.provider === "anthropic" &&
|
||||
issue.profileId === CLAUDE_CLI_PROFILE_ID
|
||||
) {
|
||||
return "Run `claude setup-token` on the gateway host.";
|
||||
}
|
||||
if (
|
||||
issue.provider === "openai-codex" &&
|
||||
issue.profileId === CODEX_CLI_PROFILE_ID
|
||||
) {
|
||||
return "Run `codex login` (or `clawdbot configure` → OpenAI Codex OAuth).";
|
||||
}
|
||||
return "Re-auth via `clawdbot configure` or `clawdbot onboard`.";
|
||||
}
|
||||
|
||||
function formatAuthIssueLine(issue: AuthIssue): string {
|
||||
const remaining =
|
||||
issue.remainingMs !== undefined
|
||||
? ` (${formatRemainingShort(issue.remainingMs)})`
|
||||
: "";
|
||||
const hint = formatAuthIssueHint(issue);
|
||||
return `- ${issue.profileId}: ${issue.status}${remaining}${hint ? ` — ${hint}` : ""}`;
|
||||
}
|
||||
|
||||
export async function noteAuthProfileHealth(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: DoctorPrompter;
|
||||
allowKeychainPrompt: boolean;
|
||||
}): Promise<void> {
|
||||
const store = ensureAuthProfileStore(undefined, {
|
||||
allowKeychainPrompt: params.allowKeychainPrompt,
|
||||
});
|
||||
let summary = buildAuthHealthSummary({
|
||||
store,
|
||||
cfg: params.cfg,
|
||||
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
|
||||
});
|
||||
|
||||
const findIssues = () =>
|
||||
summary.profiles.filter(
|
||||
(profile) =>
|
||||
profile.type === "oauth" &&
|
||||
(profile.status === "expired" ||
|
||||
profile.status === "expiring" ||
|
||||
profile.status === "missing"),
|
||||
);
|
||||
|
||||
let issues = findIssues();
|
||||
if (issues.length === 0) return;
|
||||
|
||||
const shouldRefresh = await params.prompter.confirmRepair({
|
||||
message: "Refresh expiring OAuth tokens now?",
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (shouldRefresh) {
|
||||
const refreshTargets = issues.filter((issue) =>
|
||||
["expired", "expiring", "missing"].includes(issue.status),
|
||||
);
|
||||
const errors: string[] = [];
|
||||
for (const profile of refreshTargets) {
|
||||
try {
|
||||
await resolveApiKeyForProfile({
|
||||
cfg: params.cfg,
|
||||
store,
|
||||
profileId: profile.profileId,
|
||||
});
|
||||
} catch (err) {
|
||||
errors.push(
|
||||
`- ${profile.profileId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
note(errors.join("\n"), "OAuth refresh errors");
|
||||
}
|
||||
summary = buildAuthHealthSummary({
|
||||
store: ensureAuthProfileStore(undefined, {
|
||||
allowKeychainPrompt: false,
|
||||
}),
|
||||
cfg: params.cfg,
|
||||
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
|
||||
});
|
||||
issues = findIssues();
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
note(
|
||||
issues
|
||||
.map((issue) =>
|
||||
formatAuthIssueLine({
|
||||
profileId: issue.profileId,
|
||||
provider: issue.provider,
|
||||
status: issue.status,
|
||||
remainingMs: issue.remainingMs,
|
||||
}),
|
||||
)
|
||||
.join("\n"),
|
||||
"Model auth",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ import {
|
||||
GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||
type GatewayDaemonRuntime,
|
||||
} from "./daemon-runtime.js";
|
||||
import { maybeRepairAnthropicOAuthProfileId } from "./doctor-auth.js";
|
||||
import {
|
||||
maybeRepairAnthropicOAuthProfileId,
|
||||
noteAuthProfileHealth,
|
||||
} from "./doctor-auth.js";
|
||||
import {
|
||||
buildGatewayRuntimeHints,
|
||||
formatGatewayRuntimeSummary,
|
||||
@@ -124,6 +127,12 @@ export async function doctorCommand(
|
||||
}
|
||||
|
||||
cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter);
|
||||
await noteAuthProfileHealth({
|
||||
cfg,
|
||||
prompter,
|
||||
allowKeychainPrompt:
|
||||
options.nonInteractive !== true && Boolean(process.stdin.isTTY),
|
||||
});
|
||||
const gatewayDetails = buildGatewayConnectionDetails({ config: cfg });
|
||||
if (gatewayDetails.remoteFallbackNote) {
|
||||
note(gatewayDetails.remoteFallbackNote, "Gateway");
|
||||
|
||||
38
src/commands/google-gemini-model-default.test.ts
Normal file
38
src/commands/google-gemini-model-default.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
applyGoogleGeminiModelDefault,
|
||||
GOOGLE_GEMINI_DEFAULT_MODEL,
|
||||
} from "./google-gemini-model-default.js";
|
||||
|
||||
describe("applyGoogleGeminiModelDefault", () => {
|
||||
it("sets gemini default when model is unset", () => {
|
||||
const cfg: ClawdbotConfig = { agent: {} };
|
||||
const applied = applyGoogleGeminiModelDefault(cfg);
|
||||
expect(applied.changed).toBe(true);
|
||||
expect(applied.next.agent?.model).toEqual({
|
||||
primary: GOOGLE_GEMINI_DEFAULT_MODEL,
|
||||
});
|
||||
});
|
||||
|
||||
it("overrides existing model", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agent: { model: "anthropic/claude-opus-4-5" },
|
||||
};
|
||||
const applied = applyGoogleGeminiModelDefault(cfg);
|
||||
expect(applied.changed).toBe(true);
|
||||
expect(applied.next.agent?.model).toEqual({
|
||||
primary: GOOGLE_GEMINI_DEFAULT_MODEL,
|
||||
});
|
||||
});
|
||||
|
||||
it("no-ops when already gemini default", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agent: { model: GOOGLE_GEMINI_DEFAULT_MODEL },
|
||||
};
|
||||
const applied = applyGoogleGeminiModelDefault(cfg);
|
||||
expect(applied.changed).toBe(false);
|
||||
expect(applied.next).toEqual(cfg);
|
||||
});
|
||||
});
|
||||
38
src/commands/google-gemini-model-default.ts
Normal file
38
src/commands/google-gemini-model-default.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { AgentModelListConfig } from "../config/types.js";
|
||||
|
||||
export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3-pro-preview";
|
||||
|
||||
function resolvePrimaryModel(
|
||||
model?: AgentModelListConfig | string,
|
||||
): string | undefined {
|
||||
if (typeof model === "string") return model;
|
||||
if (model && typeof model === "object" && typeof model.primary === "string") {
|
||||
return model.primary;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function applyGoogleGeminiModelDefault(cfg: ClawdbotConfig): {
|
||||
next: ClawdbotConfig;
|
||||
changed: boolean;
|
||||
} {
|
||||
const current = resolvePrimaryModel(cfg.agent?.model)?.trim();
|
||||
if (current === GOOGLE_GEMINI_DEFAULT_MODEL) {
|
||||
return { next: cfg, changed: false };
|
||||
}
|
||||
|
||||
return {
|
||||
next: {
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
model:
|
||||
cfg.agent?.model && typeof cfg.agent.model === "object"
|
||||
? { ...cfg.agent.model, primary: GOOGLE_GEMINI_DEFAULT_MODEL }
|
||||
: { primary: GOOGLE_GEMINI_DEFAULT_MODEL },
|
||||
},
|
||||
},
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
@@ -77,12 +77,17 @@ vi.mock("../../agents/agent-paths.js", () => ({
|
||||
resolveClawdbotAgentDir: mocks.resolveClawdbotAgentDir,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/auth-profiles.js", () => ({
|
||||
ensureAuthProfileStore: mocks.ensureAuthProfileStore,
|
||||
listProfilesForProvider: mocks.listProfilesForProvider,
|
||||
resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel,
|
||||
resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay,
|
||||
}));
|
||||
vi.mock("../../agents/auth-profiles.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../agents/auth-profiles.js")>();
|
||||
return {
|
||||
...actual,
|
||||
ensureAuthProfileStore: mocks.ensureAuthProfileStore,
|
||||
listProfilesForProvider: mocks.listProfilesForProvider,
|
||||
resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel,
|
||||
resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../agents/model-auth.js", () => ({
|
||||
resolveEnvApiKey: mocks.resolveEnvApiKey,
|
||||
@@ -126,6 +131,9 @@ describe("modelsStatusCommand auth overview", () => {
|
||||
expect(payload.auth.shellEnvFallback.appliedKeys).toContain(
|
||||
"OPENAI_API_KEY",
|
||||
);
|
||||
expect(payload.auth.missingProvidersInUse).toEqual([]);
|
||||
expect(payload.auth.oauth.warnAfterMs).toBeGreaterThan(0);
|
||||
expect(payload.auth.oauth.profiles.length).toBeGreaterThan(0);
|
||||
|
||||
const providers = payload.auth.providers as Array<{
|
||||
provider: string;
|
||||
@@ -152,4 +160,27 @@ describe("modelsStatusCommand auth overview", () => {
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("exits non-zero when auth is missing", async () => {
|
||||
const originalProfiles = { ...mocks.store.profiles };
|
||||
mocks.store.profiles = {};
|
||||
const localRuntime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation();
|
||||
mocks.resolveEnvApiKey.mockImplementation(() => null);
|
||||
|
||||
try {
|
||||
await modelsStatusCommand(
|
||||
{ check: true, plain: true },
|
||||
localRuntime as never,
|
||||
);
|
||||
expect(localRuntime.exit).toHaveBeenCalledWith(1);
|
||||
} finally {
|
||||
mocks.store.profiles = originalProfiles;
|
||||
mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
|
||||
import {
|
||||
buildAuthHealthSummary,
|
||||
DEFAULT_OAUTH_WARN_MS,
|
||||
formatRemainingShort,
|
||||
} from "../../agents/auth-health.js";
|
||||
import {
|
||||
type AuthProfileStore,
|
||||
ensureAuthProfileStore,
|
||||
@@ -599,7 +604,7 @@ export async function modelsListCommand(
|
||||
}
|
||||
|
||||
export async function modelsStatusCommand(
|
||||
opts: { json?: boolean; plain?: boolean },
|
||||
opts: { json?: boolean; plain?: boolean; check?: boolean },
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
ensureFlagCompatibility(opts);
|
||||
@@ -656,6 +661,7 @@ export async function modelsStatusCommand(
|
||||
.filter(Boolean),
|
||||
);
|
||||
const providersFromModels = new Set<string>();
|
||||
const providersInUse = new Set<string>();
|
||||
for (const raw of [
|
||||
defaultLabel,
|
||||
...fallbacks,
|
||||
@@ -666,6 +672,15 @@ export async function modelsStatusCommand(
|
||||
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
|
||||
if (parsed?.provider) providersFromModels.add(parsed.provider);
|
||||
}
|
||||
for (const raw of [
|
||||
defaultLabel,
|
||||
...fallbacks,
|
||||
imageModel,
|
||||
...imageFallbacks,
|
||||
]) {
|
||||
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
|
||||
if (parsed?.provider) providersInUse.add(parsed.provider);
|
||||
}
|
||||
|
||||
const providersFromEnv = new Set<string>();
|
||||
// Keep in sync with resolveEnvApiKey() mappings (we want visibility even when
|
||||
@@ -715,6 +730,12 @@ export async function modelsStatusCommand(
|
||||
Boolean(entry.modelsJson);
|
||||
return hasAny;
|
||||
});
|
||||
const providerAuthMap = new Map(
|
||||
providerAuth.map((entry) => [entry.provider, entry]),
|
||||
);
|
||||
const missingProvidersInUse = Array.from(providersInUse)
|
||||
.filter((provider) => !providerAuthMap.has(provider))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const providersWithOauth = providerAuth
|
||||
.filter(
|
||||
@@ -726,6 +747,29 @@ export async function modelsStatusCommand(
|
||||
return `${entry.provider} (${count})`;
|
||||
});
|
||||
|
||||
const authHealth = buildAuthHealthSummary({
|
||||
store,
|
||||
cfg,
|
||||
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
|
||||
providers,
|
||||
});
|
||||
const oauthProfiles = authHealth.profiles.filter(
|
||||
(profile) => profile.type === "oauth",
|
||||
);
|
||||
|
||||
const checkStatus = (() => {
|
||||
const hasExpiredOrMissing =
|
||||
oauthProfiles.some((profile) =>
|
||||
["expired", "missing"].includes(profile.status),
|
||||
) || missingProvidersInUse.length > 0;
|
||||
const hasExpiring = oauthProfiles.some(
|
||||
(profile) => profile.status === "expiring",
|
||||
);
|
||||
if (hasExpiredOrMissing) return 1;
|
||||
if (hasExpiring) return 2;
|
||||
return 0;
|
||||
})();
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
@@ -746,18 +790,30 @@ export async function modelsStatusCommand(
|
||||
appliedKeys: applied,
|
||||
},
|
||||
providersWithOAuth: providersWithOauth,
|
||||
missingProvidersInUse,
|
||||
providers: providerAuth,
|
||||
oauth: {
|
||||
warnAfterMs: authHealth.warnAfterMs,
|
||||
profiles: authHealth.profiles,
|
||||
providers: authHealth.providers,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
if (opts.check) {
|
||||
runtime.exit(checkStatus);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.plain) {
|
||||
runtime.log(resolvedLabel);
|
||||
if (opts.check) {
|
||||
runtime.exit(checkStatus);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -933,4 +989,48 @@ export async function modelsStatusCommand(
|
||||
}
|
||||
runtime.log(`- ${theme.heading(entry.provider)} ${bits.join(separator)}`);
|
||||
}
|
||||
|
||||
if (missingProvidersInUse.length > 0) {
|
||||
runtime.log("");
|
||||
runtime.log(colorize(rich, theme.heading, "Missing auth"));
|
||||
for (const provider of missingProvidersInUse) {
|
||||
const hint =
|
||||
provider === "anthropic"
|
||||
? "Run `claude setup-token` or `clawdbot configure`."
|
||||
: "Run `clawdbot configure` or set an API key env var.";
|
||||
runtime.log(`- ${theme.heading(provider)} ${hint}`);
|
||||
}
|
||||
}
|
||||
|
||||
runtime.log("");
|
||||
runtime.log(colorize(rich, theme.heading, "OAuth status"));
|
||||
if (oauthProfiles.length === 0) {
|
||||
runtime.log(colorize(rich, theme.muted, "- none"));
|
||||
return;
|
||||
}
|
||||
|
||||
const formatStatus = (status: string) => {
|
||||
if (status === "ok") return colorize(rich, theme.success, "ok");
|
||||
if (status === "expiring") return colorize(rich, theme.warn, "expiring");
|
||||
if (status === "missing") return colorize(rich, theme.warn, "unknown");
|
||||
return colorize(rich, theme.error, "expired");
|
||||
};
|
||||
|
||||
for (const profile of oauthProfiles) {
|
||||
const labelText = profile.label || profile.profileId;
|
||||
const label = colorize(rich, theme.accent, labelText);
|
||||
const status = formatStatus(profile.status);
|
||||
const expiry = profile.expiresAt
|
||||
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
|
||||
: " expires unknown";
|
||||
const source =
|
||||
profile.source !== "store"
|
||||
? colorize(rich, theme.muted, ` (${profile.source})`)
|
||||
: "";
|
||||
runtime.log(`- ${label} ${status}${expiry}${source}`);
|
||||
}
|
||||
|
||||
if (opts.check) {
|
||||
runtime.exit(checkStatus);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,19 @@ export async function setAnthropicApiKey(key: string, agentDir?: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function setGeminiApiKey(key: string, agentDir?: string) {
|
||||
// Write to the multi-agent path so gateway finds credentials on startup
|
||||
upsertAuthProfile({
|
||||
profileId: "google:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "google",
|
||||
key,
|
||||
},
|
||||
agentDir: agentDir ?? resolveDefaultAgentDir(),
|
||||
});
|
||||
}
|
||||
|
||||
export function applyAuthProfileConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
params: {
|
||||
|
||||
@@ -23,11 +23,13 @@ import {
|
||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
isGatewayDaemonRuntime,
|
||||
} from "./daemon-runtime.js";
|
||||
import { applyGoogleGeminiModelDefault } from "./google-gemini-model-default.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
applyMinimaxConfig,
|
||||
setAnthropicApiKey,
|
||||
setGeminiApiKey,
|
||||
} from "./onboard-auth.js";
|
||||
import {
|
||||
applyWizardMetadata,
|
||||
@@ -119,6 +121,20 @@ export async function runNonInteractiveOnboarding(
|
||||
provider: "anthropic",
|
||||
mode: "api_key",
|
||||
});
|
||||
} else if (authChoice === "gemini-api-key") {
|
||||
const key = opts.geminiApiKey?.trim();
|
||||
if (!key) {
|
||||
runtime.error("Missing --gemini-api-key");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
await setGeminiApiKey(key);
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "google:default",
|
||||
provider: "google",
|
||||
mode: "api_key",
|
||||
});
|
||||
nextConfig = applyGoogleGeminiModelDefault(nextConfig).next;
|
||||
} else if (authChoice === "claude-cli") {
|
||||
const store = ensureAuthProfileStore(undefined, {
|
||||
allowKeychainPrompt: false,
|
||||
|
||||
@@ -9,6 +9,7 @@ export type AuthChoice =
|
||||
| "codex-cli"
|
||||
| "antigravity"
|
||||
| "apiKey"
|
||||
| "gemini-api-key"
|
||||
| "minimax"
|
||||
| "skip";
|
||||
export type GatewayAuthChoice = "off" | "token" | "password";
|
||||
@@ -24,6 +25,7 @@ export type OnboardOptions = {
|
||||
nonInteractive?: boolean;
|
||||
authChoice?: AuthChoice;
|
||||
anthropicApiKey?: string;
|
||||
geminiApiKey?: string;
|
||||
gatewayPort?: number;
|
||||
gatewayBind?: GatewayBind;
|
||||
gatewayAuth?: GatewayAuthChoice;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import { getResolvedLoggerSettings } from "../../logging.js";
|
||||
import { parseLogLine } from "../../logging/parse-log-line.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
|
||||
@@ -10,14 +11,7 @@ export type ProvidersLogsOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type LogLine = {
|
||||
time?: string;
|
||||
level?: string;
|
||||
subsystem?: string;
|
||||
module?: string;
|
||||
message: string;
|
||||
raw: string;
|
||||
};
|
||||
type LogLine = ReturnType<typeof parseLogLine>;
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_BYTES = 1_000_000;
|
||||
@@ -37,59 +31,7 @@ function parseProviderFilter(raw?: string) {
|
||||
return PROVIDERS.has(trimmed) ? trimmed : "all";
|
||||
}
|
||||
|
||||
function extractMessage(value: Record<string, unknown>): string {
|
||||
const parts: string[] = [];
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!/^\d+$/.test(key)) continue;
|
||||
const item = value[key];
|
||||
if (typeof item === "string") {
|
||||
parts.push(item);
|
||||
} else if (item != null) {
|
||||
parts.push(JSON.stringify(item));
|
||||
}
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function parseMetaName(raw?: unknown): { subsystem?: string; module?: string } {
|
||||
if (typeof raw !== "string") return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
subsystem:
|
||||
typeof parsed.subsystem === "string" ? parsed.subsystem : undefined,
|
||||
module: typeof parsed.module === "string" ? parsed.module : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function parseLogLine(raw: string): LogLine | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const meta = parsed._meta as Record<string, unknown> | undefined;
|
||||
const nameMeta = parseMetaName(meta?.name);
|
||||
return {
|
||||
time:
|
||||
typeof parsed.time === "string"
|
||||
? parsed.time
|
||||
: typeof meta?.date === "string"
|
||||
? meta.date
|
||||
: undefined,
|
||||
level:
|
||||
typeof meta?.logLevelName === "string" ? meta.logLevelName : undefined,
|
||||
subsystem: nameMeta.subsystem,
|
||||
module: nameMeta.module,
|
||||
message: extractMessage(parsed),
|
||||
raw,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function matchesProvider(line: LogLine, provider: string) {
|
||||
function matchesProvider(line: NonNullable<LogLine>, provider: string) {
|
||||
if (provider === "all") return true;
|
||||
const needle = `gateway/providers/${provider}`;
|
||||
if (line.subsystem?.includes(needle)) return true;
|
||||
@@ -139,7 +81,7 @@ export async function providersLogsCommand(
|
||||
const rawLines = await readTailLines(file, limit * 4);
|
||||
const parsed = rawLines
|
||||
.map(parseLogLine)
|
||||
.filter((line): line is LogLine => Boolean(line));
|
||||
.filter((line): line is NonNullable<LogLine> => Boolean(line));
|
||||
const filtered = parsed.filter((line) => matchesProvider(line, provider));
|
||||
const lines = filtered.slice(Math.max(0, filtered.length - limit));
|
||||
|
||||
|
||||
@@ -154,6 +154,10 @@ function buildSystemdUnit({
|
||||
`ExecStart=${execStart}`,
|
||||
"Restart=always",
|
||||
"RestartSec=5",
|
||||
// KillMode=process ensures systemd only waits for the main process to exit.
|
||||
// Without this, podman's conmon (container monitor) processes block shutdown
|
||||
// since they run as children of the gateway and stay in the same cgroup.
|
||||
"KillMode=process",
|
||||
workingDirLine,
|
||||
...envLines,
|
||||
"",
|
||||
|
||||
@@ -785,6 +785,7 @@ export function createDiscordMessageHandler(params: {
|
||||
!hasAnyMention &&
|
||||
commandAuthorized &&
|
||||
hasControlCommand(baseText);
|
||||
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
|
||||
const canDetectMention = Boolean(botId) || mentionRegexes.length > 0;
|
||||
if (isGuildMessage && shouldRequireMention) {
|
||||
if (botId && !wasMentioned && !shouldBypassMention) {
|
||||
@@ -981,7 +982,7 @@ export function createDiscordMessageHandler(params: {
|
||||
: undefined,
|
||||
Provider: "discord" as const,
|
||||
Surface: "discord" as const,
|
||||
WasMentioned: wasMentioned,
|
||||
WasMentioned: effectiveWasMentioned,
|
||||
MessageSid: message.id,
|
||||
ParentSessionKey: threadKeys.parentSessionKey,
|
||||
ThreadStarterBody: threadStarterBody,
|
||||
|
||||
@@ -326,6 +326,7 @@ export async function monitorIMessageProvider(
|
||||
!mentioned &&
|
||||
commandAuthorized &&
|
||||
hasControlCommand(messageText);
|
||||
const effectiveWasMentioned = mentioned || shouldBypassMention;
|
||||
if (
|
||||
isGroup &&
|
||||
requireMention &&
|
||||
@@ -387,7 +388,7 @@ export async function monitorIMessageProvider(
|
||||
MediaPath: mediaPath,
|
||||
MediaType: mediaType,
|
||||
MediaUrl: mediaPath,
|
||||
WasMentioned: mentioned,
|
||||
WasMentioned: effectiveWasMentioned,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
// Originating channel for reply routing.
|
||||
OriginatingChannel: "imessage" as const,
|
||||
|
||||
46
src/logging/parse-log-line.test.ts
Normal file
46
src/logging/parse-log-line.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseLogLine } from "./parse-log-line.js";
|
||||
|
||||
describe("parseLogLine", () => {
|
||||
it("parses structured JSON log lines", () => {
|
||||
const line = JSON.stringify({
|
||||
time: "2026-01-09T01:38:41.523Z",
|
||||
0: '{"subsystem":"gateway/providers/whatsapp"}',
|
||||
1: "connected",
|
||||
_meta: {
|
||||
name: '{"subsystem":"gateway/providers/whatsapp"}',
|
||||
logLevelName: "INFO",
|
||||
},
|
||||
});
|
||||
|
||||
const parsed = parseLogLine(line);
|
||||
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.time).toBe("2026-01-09T01:38:41.523Z");
|
||||
expect(parsed?.level).toBe("info");
|
||||
expect(parsed?.subsystem).toBe("gateway/providers/whatsapp");
|
||||
expect(parsed?.message).toBe("{\"subsystem\":\"gateway/providers/whatsapp\"} connected");
|
||||
expect(parsed?.raw).toBe(line);
|
||||
});
|
||||
|
||||
it("falls back to meta timestamp when top-level time is missing", () => {
|
||||
const line = JSON.stringify({
|
||||
0: "hello",
|
||||
_meta: {
|
||||
name: "{\"subsystem\":\"gateway\"}",
|
||||
logLevelName: "WARN",
|
||||
date: "2026-01-09T02:10:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
const parsed = parseLogLine(line);
|
||||
|
||||
expect(parsed?.time).toBe("2026-01-09T02:10:00.000Z");
|
||||
expect(parsed?.level).toBe("warn");
|
||||
});
|
||||
|
||||
it("returns null for invalid JSON", () => {
|
||||
expect(parseLogLine("not-json")).toBeNull();
|
||||
});
|
||||
});
|
||||
63
src/logging/parse-log-line.ts
Normal file
63
src/logging/parse-log-line.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export type ParsedLogLine = {
|
||||
time?: string;
|
||||
level?: string;
|
||||
subsystem?: string;
|
||||
module?: string;
|
||||
message: string;
|
||||
raw: string;
|
||||
};
|
||||
|
||||
function extractMessage(value: Record<string, unknown>): string {
|
||||
const parts: string[] = [];
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!/^\d+$/.test(key)) continue;
|
||||
const item = value[key];
|
||||
if (typeof item === "string") {
|
||||
parts.push(item);
|
||||
} else if (item != null) {
|
||||
parts.push(JSON.stringify(item));
|
||||
}
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function parseMetaName(
|
||||
raw?: unknown,
|
||||
): { subsystem?: string; module?: string } {
|
||||
if (typeof raw !== "string") return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
subsystem:
|
||||
typeof parsed.subsystem === "string" ? parsed.subsystem : undefined,
|
||||
module: typeof parsed.module === "string" ? parsed.module : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLogLine(raw: string): ParsedLogLine | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const meta = parsed._meta as Record<string, unknown> | undefined;
|
||||
const nameMeta = parseMetaName(meta?.name);
|
||||
const levelRaw =
|
||||
typeof meta?.logLevelName === "string" ? meta.logLevelName : undefined;
|
||||
return {
|
||||
time:
|
||||
typeof parsed.time === "string"
|
||||
? parsed.time
|
||||
: typeof meta?.date === "string"
|
||||
? meta.date
|
||||
: undefined,
|
||||
level: levelRaw ? levelRaw.toLowerCase() : undefined,
|
||||
subsystem: nameMeta.subsystem,
|
||||
module: nameMeta.module,
|
||||
message: extractMessage(parsed),
|
||||
raw,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ const makeModel = (id: string): Model<"google-generative-ai"> =>
|
||||
}) as Model<"google-generative-ai">;
|
||||
|
||||
describe("google-shared convertTools", () => {
|
||||
it("adds type:object when properties/required exist but type is missing", () => {
|
||||
it("preserves parameters when type is missing", () => {
|
||||
const tools = [
|
||||
{
|
||||
name: "noType",
|
||||
@@ -46,12 +46,12 @@ describe("google-shared convertTools", () => {
|
||||
converted?.[0]?.functionDeclarations?.[0]?.parameters,
|
||||
);
|
||||
|
||||
expect(params.type).toBe("object");
|
||||
expect(params.type).toBeUndefined();
|
||||
expect(params.properties).toBeDefined();
|
||||
expect(params.required).toEqual(["action"]);
|
||||
});
|
||||
|
||||
it("strips unsupported JSON Schema keywords", () => {
|
||||
it("keeps unsupported JSON Schema keywords intact", () => {
|
||||
const tools = [
|
||||
{
|
||||
name: "example",
|
||||
@@ -93,11 +93,11 @@ describe("google-shared convertTools", () => {
|
||||
const list = asRecord(properties.list);
|
||||
const items = asRecord(list.items);
|
||||
|
||||
expect(params).not.toHaveProperty("patternProperties");
|
||||
expect(params).not.toHaveProperty("additionalProperties");
|
||||
expect(mode).not.toHaveProperty("const");
|
||||
expect(options).not.toHaveProperty("anyOf");
|
||||
expect(items).not.toHaveProperty("const");
|
||||
expect(params).toHaveProperty("patternProperties");
|
||||
expect(params).toHaveProperty("additionalProperties");
|
||||
expect(mode).toHaveProperty("const");
|
||||
expect(options).toHaveProperty("anyOf");
|
||||
expect(items).toHaveProperty("const");
|
||||
expect(params.required).toEqual(["mode"]);
|
||||
});
|
||||
|
||||
@@ -147,7 +147,7 @@ describe("google-shared convertTools", () => {
|
||||
});
|
||||
|
||||
describe("google-shared convertMessages", () => {
|
||||
it("skips thinking blocks for Gemini to avoid mimicry", () => {
|
||||
it("keeps thinking blocks when provider/model match", () => {
|
||||
const model = makeModel("gemini-1.5-pro");
|
||||
const context = {
|
||||
messages: [
|
||||
@@ -184,7 +184,13 @@ describe("google-shared convertMessages", () => {
|
||||
} as unknown as Context;
|
||||
|
||||
const contents = convertMessages(model, context);
|
||||
expect(contents).toHaveLength(0);
|
||||
expect(contents).toHaveLength(1);
|
||||
expect(contents[0].role).toBe("model");
|
||||
expect(contents[0].parts).toHaveLength(1);
|
||||
expect(contents[0].parts?.[0]).toMatchObject({
|
||||
thought: true,
|
||||
thoughtSignature: "sig",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps thought signatures for Claude models", () => {
|
||||
@@ -232,7 +238,7 @@ describe("google-shared convertMessages", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("merges consecutive user messages to satisfy Gemini role alternation", () => {
|
||||
it("does not merge consecutive user messages for Gemini", () => {
|
||||
const model = makeModel("gemini-1.5-pro");
|
||||
const context = {
|
||||
messages: [
|
||||
@@ -248,12 +254,12 @@ describe("google-shared convertMessages", () => {
|
||||
} as unknown as Context;
|
||||
|
||||
const contents = convertMessages(model, context);
|
||||
expect(contents).toHaveLength(1);
|
||||
expect(contents).toHaveLength(2);
|
||||
expect(contents[0].role).toBe("user");
|
||||
expect(contents[0].parts).toHaveLength(2);
|
||||
expect(contents[1].role).toBe("user");
|
||||
});
|
||||
|
||||
it("merges consecutive user messages for non-Gemini Google models", () => {
|
||||
it("does not merge consecutive user messages for non-Gemini Google models", () => {
|
||||
const model = makeModel("claude-3-opus");
|
||||
const context = {
|
||||
messages: [
|
||||
@@ -269,12 +275,12 @@ describe("google-shared convertMessages", () => {
|
||||
} as unknown as Context;
|
||||
|
||||
const contents = convertMessages(model, context);
|
||||
expect(contents).toHaveLength(1);
|
||||
expect(contents).toHaveLength(2);
|
||||
expect(contents[0].role).toBe("user");
|
||||
expect(contents[0].parts).toHaveLength(2);
|
||||
expect(contents[1].role).toBe("user");
|
||||
});
|
||||
|
||||
it("merges consecutive model messages to satisfy Gemini role alternation", () => {
|
||||
it("does not merge consecutive model messages for Gemini", () => {
|
||||
const model = makeModel("gemini-1.5-pro");
|
||||
const context = {
|
||||
messages: [
|
||||
@@ -332,10 +338,10 @@ describe("google-shared convertMessages", () => {
|
||||
} as unknown as Context;
|
||||
|
||||
const contents = convertMessages(model, context);
|
||||
expect(contents).toHaveLength(2);
|
||||
expect(contents).toHaveLength(3);
|
||||
expect(contents[0].role).toBe("user");
|
||||
expect(contents[1].role).toBe("model");
|
||||
expect(contents[1].parts).toHaveLength(2);
|
||||
expect(contents[2].role).toBe("model");
|
||||
});
|
||||
|
||||
it("handles user message after tool result without model response in between", () => {
|
||||
@@ -392,10 +398,11 @@ describe("google-shared convertMessages", () => {
|
||||
} as unknown as Context;
|
||||
|
||||
const contents = convertMessages(model, context);
|
||||
expect(contents).toHaveLength(3);
|
||||
expect(contents).toHaveLength(4);
|
||||
expect(contents[0].role).toBe("user");
|
||||
expect(contents[1].role).toBe("model");
|
||||
expect(contents[2].role).toBe("user");
|
||||
expect(contents[3].role).toBe("user");
|
||||
const toolResponsePart = contents[2].parts?.find(
|
||||
(part) =>
|
||||
typeof part === "object" && part !== null && "functionResponse" in part,
|
||||
@@ -469,10 +476,11 @@ describe("google-shared convertMessages", () => {
|
||||
} as unknown as Context;
|
||||
|
||||
const contents = convertMessages(model, context);
|
||||
expect(contents).toHaveLength(2);
|
||||
expect(contents).toHaveLength(3);
|
||||
expect(contents[0].role).toBe("user");
|
||||
expect(contents[1].role).toBe("model");
|
||||
const toolCallPart = contents[1].parts?.find(
|
||||
expect(contents[2].role).toBe("model");
|
||||
const toolCallPart = contents[2].parts?.find(
|
||||
(part) =>
|
||||
typeof part === "object" && part !== null && "functionCall" in part,
|
||||
);
|
||||
|
||||
@@ -250,6 +250,39 @@ describe("monitorSlackProvider tool results", () => {
|
||||
expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true);
|
||||
});
|
||||
|
||||
it("treats control commands as mentions for group bypass", async () => {
|
||||
replyMock.mockResolvedValue({ text: "ok" });
|
||||
|
||||
const controller = new AbortController();
|
||||
const run = monitorSlackProvider({
|
||||
botToken: "bot-token",
|
||||
appToken: "app-token",
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
await handler({
|
||||
event: {
|
||||
type: "message",
|
||||
user: "U1",
|
||||
text: "/elevated off",
|
||||
ts: "123",
|
||||
channel: "C1",
|
||||
channel_type: "channel",
|
||||
},
|
||||
});
|
||||
|
||||
await flush();
|
||||
controller.abort();
|
||||
await run;
|
||||
|
||||
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||
expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true);
|
||||
});
|
||||
|
||||
it("threads replies when incoming message is in a thread", async () => {
|
||||
replyMock.mockResolvedValue({ text: "thread reply" });
|
||||
|
||||
|
||||
@@ -913,6 +913,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
!hasAnyMention &&
|
||||
commandAuthorized &&
|
||||
hasControlCommand(message.text ?? "");
|
||||
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
|
||||
const canDetectMention = Boolean(botUserId) || mentionRegexes.length > 0;
|
||||
if (
|
||||
isRoom &&
|
||||
@@ -1058,7 +1059,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
ThreadStarterBody: threadStarterBody,
|
||||
ThreadLabel: threadLabel,
|
||||
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
|
||||
WasMentioned: isRoomish ? wasMentioned : undefined,
|
||||
WasMentioned: isRoomish ? effectiveWasMentioned : undefined,
|
||||
MediaPath: media?.path,
|
||||
MediaType: media?.contentType,
|
||||
MediaUrl: media?.path,
|
||||
|
||||
@@ -486,6 +486,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
!hasAnyMention &&
|
||||
commandAuthorized &&
|
||||
hasControlCommand(msg.text ?? msg.caption ?? "");
|
||||
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
|
||||
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
|
||||
if (isGroup && requireMention && canDetectMention) {
|
||||
if (!wasMentioned && !shouldBypassMention) {
|
||||
@@ -592,7 +593,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
ReplyToBody: replyTarget?.body,
|
||||
ReplyToSender: replyTarget?.sender,
|
||||
Timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||
WasMentioned: isGroup ? wasMentioned : undefined,
|
||||
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
|
||||
MediaPath: allMedia[0]?.path,
|
||||
MediaType: allMedia[0]?.contentType,
|
||||
MediaUrl: allMedia[0]?.path,
|
||||
|
||||
@@ -1132,6 +1132,45 @@ describe("web auto-reply", () => {
|
||||
expect(payload.Body).toContain("[from: Bob (+222)]");
|
||||
});
|
||||
|
||||
it("sets OriginatingTo to the sender for queued routing", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (
|
||||
msg: import("./inbound.js").WebInboundMessage,
|
||||
) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
|
||||
await monitorWebProvider(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello",
|
||||
from: "+15551234567",
|
||||
to: "+19998887777",
|
||||
id: "m-originating",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(resolver).toHaveBeenCalledTimes(1);
|
||||
const payload = resolver.mock.calls[0][0];
|
||||
expect(payload.OriginatingChannel).toBe("whatsapp");
|
||||
expect(payload.OriginatingTo).toBe("+15551234567");
|
||||
expect(payload.To).toBe("+19998887777");
|
||||
expect(payload.OriginatingTo).not.toBe(payload.To);
|
||||
});
|
||||
|
||||
it("uses per-agent mention patterns for group gating", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -1261,7 +1261,7 @@ export async function monitorWebProvider(
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
OriginatingChannel: "whatsapp",
|
||||
OriginatingTo: msg.to,
|
||||
OriginatingTo: msg.from,
|
||||
},
|
||||
cfg,
|
||||
dispatcher,
|
||||
|
||||
Reference in New Issue
Block a user