mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 13:31:23 +00:00
chore: migrate to oxlint and oxfmt
Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
This commit is contained in:
@@ -1,9 +1,4 @@
|
||||
export type AgentEventStream =
|
||||
| "lifecycle"
|
||||
| "tool"
|
||||
| "assistant"
|
||||
| "error"
|
||||
| (string & {});
|
||||
export type AgentEventStream = "lifecycle" | "tool" | "assistant" | "error" | (string & {});
|
||||
|
||||
export type AgentEventPayload = {
|
||||
runId: string;
|
||||
@@ -24,10 +19,7 @@ const seqByRun = new Map<string, number>();
|
||||
const listeners = new Set<(evt: AgentEventPayload) => void>();
|
||||
const runContextById = new Map<string, AgentRunContext>();
|
||||
|
||||
export function registerAgentRunContext(
|
||||
runId: string,
|
||||
context: AgentRunContext,
|
||||
) {
|
||||
export function registerAgentRunContext(runId: string, context: AgentRunContext) {
|
||||
if (!runId) return;
|
||||
const existing = runContextById.get(runId);
|
||||
if (!existing) {
|
||||
|
||||
@@ -20,19 +20,15 @@ describe("ensureBinary", () => {
|
||||
});
|
||||
|
||||
it("logs and exits when missing", async () => {
|
||||
const exec: typeof runExec = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("missing"));
|
||||
const exec: typeof runExec = vi.fn().mockRejectedValue(new Error("missing"));
|
||||
const error = vi.fn();
|
||||
const exit = vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
});
|
||||
await expect(
|
||||
ensureBinary("ghost", exec, { log: vi.fn(), error, exit }),
|
||||
).rejects.toThrow("exit");
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
"Missing required binary: ghost. Please install it.",
|
||||
await expect(ensureBinary("ghost", exec, { log: vi.fn(), error, exit })).rejects.toThrow(
|
||||
"exit",
|
||||
);
|
||||
expect(error).toHaveBeenCalledWith("Missing required binary: ghost. Please install it.");
|
||||
expect(exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,6 @@ export function ignoreCiaoCancellationRejection(reason: unknown): boolean {
|
||||
if (!message.includes("CIAO ANNOUNCEMENT CANCELLED")) {
|
||||
return false;
|
||||
}
|
||||
logDebug(
|
||||
`bonjour: ignoring unhandled ciao rejection: ${formatBonjourError(reason)}`,
|
||||
);
|
||||
logDebug(`bonjour: ignoring unhandled ciao rejection: ${formatBonjourError(reason)}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -9,67 +9,16 @@ describe("bonjour-discovery", () => {
|
||||
const calls: Array<{ argv: string[]; timeoutMs: number }> = [];
|
||||
const studioInstance = "Peter’s Mac Studio Bridge";
|
||||
|
||||
const run = vi.fn(
|
||||
async (argv: string[], options: { timeoutMs: number }) => {
|
||||
calls.push({ argv, timeoutMs: options.timeoutMs });
|
||||
const domain = argv[3] ?? "";
|
||||
|
||||
if (argv[0] === "dns-sd" && argv[1] === "-B") {
|
||||
if (domain === "local.") {
|
||||
return {
|
||||
stdout: [
|
||||
"Add 2 3 local. _clawdbot-bridge._tcp. Peter\\226\\128\\153s Mac Studio Bridge",
|
||||
"Add 2 3 local. _clawdbot-bridge._tcp. Laptop Bridge",
|
||||
"",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
}
|
||||
if (domain === WIDE_AREA_DISCOVERY_DOMAIN) {
|
||||
return {
|
||||
stdout: [
|
||||
`Add 2 3 ${WIDE_AREA_DISCOVERY_DOMAIN} _clawdbot-bridge._tcp. Tailnet Bridge`,
|
||||
"",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (argv[0] === "dns-sd" && argv[1] === "-L") {
|
||||
const instance = argv[2] ?? "";
|
||||
const host =
|
||||
instance === studioInstance
|
||||
? "studio.local"
|
||||
: instance === "Laptop Bridge"
|
||||
? "laptop.local"
|
||||
: "tailnet.local";
|
||||
const tailnetDns =
|
||||
instance === "Tailnet Bridge" ? "studio.tailnet.ts.net" : "";
|
||||
const displayName =
|
||||
instance === studioInstance
|
||||
? "Peter’s\\032Mac\\032Studio"
|
||||
: instance.replace(" Bridge", "");
|
||||
const txtParts = [
|
||||
"txtvers=1",
|
||||
`displayName=${displayName}`,
|
||||
`lanHost=${host}`,
|
||||
"gatewayPort=18789",
|
||||
"bridgePort=18790",
|
||||
"sshPort=22",
|
||||
tailnetDns ? `tailnetDns=${tailnetDns}` : null,
|
||||
].filter((v): v is string => Boolean(v));
|
||||
const run = vi.fn(async (argv: string[], options: { timeoutMs: number }) => {
|
||||
calls.push({ argv, timeoutMs: options.timeoutMs });
|
||||
const domain = argv[3] ?? "";
|
||||
|
||||
if (argv[0] === "dns-sd" && argv[1] === "-B") {
|
||||
if (domain === "local.") {
|
||||
return {
|
||||
stdout: [
|
||||
`${instance}._clawdbot-bridge._tcp. can be reached at ${host}:18790`,
|
||||
txtParts.join(" "),
|
||||
"Add 2 3 local. _clawdbot-bridge._tcp. Peter\\226\\128\\153s Mac Studio Bridge",
|
||||
"Add 2 3 local. _clawdbot-bridge._tcp. Laptop Bridge",
|
||||
"",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
@@ -78,10 +27,58 @@ describe("bonjour-discovery", () => {
|
||||
killed: false,
|
||||
};
|
||||
}
|
||||
if (domain === WIDE_AREA_DISCOVERY_DOMAIN) {
|
||||
return {
|
||||
stdout: [
|
||||
`Add 2 3 ${WIDE_AREA_DISCOVERY_DOMAIN} _clawdbot-bridge._tcp. Tailnet Bridge`,
|
||||
"",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`unexpected argv: ${argv.join(" ")}`);
|
||||
},
|
||||
);
|
||||
if (argv[0] === "dns-sd" && argv[1] === "-L") {
|
||||
const instance = argv[2] ?? "";
|
||||
const host =
|
||||
instance === studioInstance
|
||||
? "studio.local"
|
||||
: instance === "Laptop Bridge"
|
||||
? "laptop.local"
|
||||
: "tailnet.local";
|
||||
const tailnetDns = instance === "Tailnet Bridge" ? "studio.tailnet.ts.net" : "";
|
||||
const displayName =
|
||||
instance === studioInstance
|
||||
? "Peter’s\\032Mac\\032Studio"
|
||||
: instance.replace(" Bridge", "");
|
||||
const txtParts = [
|
||||
"txtvers=1",
|
||||
`displayName=${displayName}`,
|
||||
`lanHost=${host}`,
|
||||
"gatewayPort=18789",
|
||||
"bridgePort=18790",
|
||||
"sshPort=22",
|
||||
tailnetDns ? `tailnetDns=${tailnetDns}` : null,
|
||||
].filter((v): v is string => Boolean(v));
|
||||
|
||||
return {
|
||||
stdout: [
|
||||
`${instance}._clawdbot-bridge._tcp. can be reached at ${host}:18790`,
|
||||
txtParts.join(" "),
|
||||
"",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`unexpected argv: ${argv.join(" ")}`);
|
||||
});
|
||||
|
||||
const beacons = await discoverGatewayBeacons({
|
||||
platform: "darwin",
|
||||
@@ -102,9 +99,7 @@ describe("bonjour-discovery", () => {
|
||||
expect.arrayContaining(["local.", WIDE_AREA_DISCOVERY_DOMAIN]),
|
||||
);
|
||||
|
||||
const browseCalls = calls.filter(
|
||||
(c) => c.argv[0] === "dns-sd" && c.argv[1] === "-B",
|
||||
);
|
||||
const browseCalls = calls.filter((c) => c.argv[0] === "dns-sd" && c.argv[1] === "-B");
|
||||
expect(browseCalls.map((c) => c.argv[3])).toEqual(
|
||||
expect.arrayContaining(["local.", WIDE_AREA_DISCOVERY_DOMAIN]),
|
||||
);
|
||||
@@ -112,47 +107,42 @@ describe("bonjour-discovery", () => {
|
||||
});
|
||||
|
||||
it("decodes dns-sd octal escapes in TXT displayName", async () => {
|
||||
const run = vi.fn(
|
||||
async (argv: string[], options: { timeoutMs: number }) => {
|
||||
if (options.timeoutMs < 0) throw new Error("invalid timeout");
|
||||
|
||||
const domain = argv[3] ?? "";
|
||||
if (argv[0] === "dns-sd" && argv[1] === "-B" && domain === "local.") {
|
||||
return {
|
||||
stdout: [
|
||||
"Add 2 3 local. _clawdbot-bridge._tcp. Studio Bridge",
|
||||
"",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (argv[0] === "dns-sd" && argv[1] === "-L") {
|
||||
return {
|
||||
stdout: [
|
||||
"Studio Bridge._clawdbot-bridge._tcp. can be reached at studio.local:18790",
|
||||
"txtvers=1 displayName=Peter\\226\\128\\153s\\032Mac\\032Studio lanHost=studio.local gatewayPort=18789 bridgePort=18790 sshPort=22",
|
||||
"",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
}
|
||||
const run = vi.fn(async (argv: string[], options: { timeoutMs: number }) => {
|
||||
if (options.timeoutMs < 0) throw new Error("invalid timeout");
|
||||
|
||||
const domain = argv[3] ?? "";
|
||||
if (argv[0] === "dns-sd" && argv[1] === "-B" && domain === "local.") {
|
||||
return {
|
||||
stdout: "",
|
||||
stdout: ["Add 2 3 local. _clawdbot-bridge._tcp. Studio Bridge", ""].join("\n"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (argv[0] === "dns-sd" && argv[1] === "-L") {
|
||||
return {
|
||||
stdout: [
|
||||
"Studio Bridge._clawdbot-bridge._tcp. can be reached at studio.local:18790",
|
||||
"txtvers=1 displayName=Peter\\226\\128\\153s\\032Mac\\032Studio lanHost=studio.local gatewayPort=18789 bridgePort=18790 sshPort=22",
|
||||
"",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
});
|
||||
|
||||
const beacons = await discoverGatewayBeacons({
|
||||
platform: "darwin",
|
||||
@@ -176,14 +166,48 @@ describe("bonjour-discovery", () => {
|
||||
it("falls back to tailnet DNS probing for wide-area when split DNS is not configured", async () => {
|
||||
const calls: Array<{ argv: string[]; timeoutMs: number }> = [];
|
||||
|
||||
const run = vi.fn(
|
||||
async (argv: string[], options: { timeoutMs: number }) => {
|
||||
calls.push({ argv, timeoutMs: options.timeoutMs });
|
||||
const cmd = argv[0];
|
||||
const run = vi.fn(async (argv: string[], options: { timeoutMs: number }) => {
|
||||
calls.push({ argv, timeoutMs: options.timeoutMs });
|
||||
const cmd = argv[0];
|
||||
|
||||
if (cmd === "dns-sd" && argv[1] === "-B") {
|
||||
if (cmd === "dns-sd" && argv[1] === "-B") {
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (cmd === "tailscale" && argv[1] === "status" && argv[2] === "--json") {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
Self: { TailscaleIPs: ["100.69.232.64"] },
|
||||
Peer: {
|
||||
"peer-1": { TailscaleIPs: ["100.123.224.76"] },
|
||||
},
|
||||
}),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (cmd === "dig") {
|
||||
const at = argv.find((a) => a.startsWith("@")) ?? "";
|
||||
const server = at.replace(/^@/, "");
|
||||
const qname = argv[argv.length - 2] ?? "";
|
||||
const qtype = argv[argv.length - 1] ?? "";
|
||||
|
||||
if (
|
||||
server === "100.123.224.76" &&
|
||||
qtype === "PTR" &&
|
||||
qname === "_clawdbot-bridge._tcp.clawdbot.internal"
|
||||
) {
|
||||
return {
|
||||
stdout: "",
|
||||
stdout: `studio-bridge._clawdbot-bridge._tcp.clawdbot.internal.\n`,
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
@@ -192,17 +216,12 @@ describe("bonjour-discovery", () => {
|
||||
}
|
||||
|
||||
if (
|
||||
cmd === "tailscale" &&
|
||||
argv[1] === "status" &&
|
||||
argv[2] === "--json"
|
||||
server === "100.123.224.76" &&
|
||||
qtype === "SRV" &&
|
||||
qname === "studio-bridge._clawdbot-bridge._tcp.clawdbot.internal"
|
||||
) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
Self: { TailscaleIPs: ["100.69.232.64"] },
|
||||
Peer: {
|
||||
"peer-1": { TailscaleIPs: ["100.123.224.76"] },
|
||||
},
|
||||
}),
|
||||
stdout: `0 0 18790 studio.clawdbot.internal.\n`,
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
@@ -210,67 +229,32 @@ describe("bonjour-discovery", () => {
|
||||
};
|
||||
}
|
||||
|
||||
if (cmd === "dig") {
|
||||
const at = argv.find((a) => a.startsWith("@")) ?? "";
|
||||
const server = at.replace(/^@/, "");
|
||||
const qname = argv[argv.length - 2] ?? "";
|
||||
const qtype = argv[argv.length - 1] ?? "";
|
||||
|
||||
if (
|
||||
server === "100.123.224.76" &&
|
||||
qtype === "PTR" &&
|
||||
qname === "_clawdbot-bridge._tcp.clawdbot.internal"
|
||||
) {
|
||||
return {
|
||||
stdout: `studio-bridge._clawdbot-bridge._tcp.clawdbot.internal.\n`,
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
server === "100.123.224.76" &&
|
||||
qtype === "SRV" &&
|
||||
qname === "studio-bridge._clawdbot-bridge._tcp.clawdbot.internal"
|
||||
) {
|
||||
return {
|
||||
stdout: `0 0 18790 studio.clawdbot.internal.\n`,
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
server === "100.123.224.76" &&
|
||||
qtype === "TXT" &&
|
||||
qname === "studio-bridge._clawdbot-bridge._tcp.clawdbot.internal"
|
||||
) {
|
||||
return {
|
||||
stdout: [
|
||||
`"displayName=Studio"`,
|
||||
`"transport=bridge"`,
|
||||
`"bridgePort=18790"`,
|
||||
`"gatewayPort=18789"`,
|
||||
`"sshPort=22"`,
|
||||
`"tailnetDns=peters-mac-studio-1.sheep-coho.ts.net"`,
|
||||
`"cliPath=/opt/homebrew/bin/clawdbot"`,
|
||||
"",
|
||||
].join(" "),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
server === "100.123.224.76" &&
|
||||
qtype === "TXT" &&
|
||||
qname === "studio-bridge._clawdbot-bridge._tcp.clawdbot.internal"
|
||||
) {
|
||||
return {
|
||||
stdout: [
|
||||
`"displayName=Studio"`,
|
||||
`"transport=bridge"`,
|
||||
`"bridgePort=18790"`,
|
||||
`"gatewayPort=18789"`,
|
||||
`"sshPort=22"`,
|
||||
`"tailnetDns=peters-mac-studio-1.sheep-coho.ts.net"`,
|
||||
`"cliPath=/opt/homebrew/bin/clawdbot"`,
|
||||
"",
|
||||
].join(" "),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`unexpected argv: ${argv.join(" ")}`);
|
||||
},
|
||||
);
|
||||
throw new Error(`unexpected argv: ${argv.join(" ")}`);
|
||||
});
|
||||
|
||||
const beacons = await discoverGatewayBeacons({
|
||||
platform: "darwin",
|
||||
@@ -293,9 +277,7 @@ describe("bonjour-discovery", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
calls.some((c) => c.argv[0] === "tailscale" && c.argv[1] === "status"),
|
||||
).toBe(true);
|
||||
expect(calls.some((c) => c.argv[0] === "tailscale" && c.argv[1] === "status")).toBe(true);
|
||||
expect(calls.some((c) => c.argv[0] === "dig")).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -89,10 +89,7 @@ function parseDigTxt(stdout: string): string[] {
|
||||
if (!line) continue;
|
||||
const matches = Array.from(line.matchAll(/"([^"]*)"/g), (m) => m[1] ?? "");
|
||||
for (const m of matches) {
|
||||
const unescaped = m
|
||||
.replaceAll("\\\\", "\\")
|
||||
.replaceAll('\\"', '"')
|
||||
.replaceAll("\\n", "\n");
|
||||
const unescaped = m.replaceAll("\\\\", "\\").replaceAll('\\"', '"').replaceAll("\\n", "\n");
|
||||
tokens.push(unescaped);
|
||||
}
|
||||
}
|
||||
@@ -176,10 +173,7 @@ function parseDnsSdBrowse(stdout: string): string[] {
|
||||
return Array.from(instances.values());
|
||||
}
|
||||
|
||||
function parseDnsSdResolve(
|
||||
stdout: string,
|
||||
instanceName: string,
|
||||
): GatewayBonjourBeacon | null {
|
||||
function parseDnsSdResolve(stdout: string, instanceName: string): GatewayBonjourBeacon | null {
|
||||
const decodedInstanceName = decodeDnsSdEscapes(instanceName);
|
||||
const beacon: GatewayBonjourBeacon = { instanceName: decodedInstanceName };
|
||||
let txt: Record<string, string> = {};
|
||||
@@ -228,10 +222,9 @@ async function discoverViaDnsSd(
|
||||
const instances = parseDnsSdBrowse(browse.stdout);
|
||||
const results: GatewayBonjourBeacon[] = [];
|
||||
for (const instance of instances) {
|
||||
const resolved = await run(
|
||||
["dns-sd", "-L", instance, "_clawdbot-bridge._tcp", domain],
|
||||
{ timeoutMs },
|
||||
);
|
||||
const resolved = await run(["dns-sd", "-L", instance, "_clawdbot-bridge._tcp", domain], {
|
||||
timeoutMs,
|
||||
});
|
||||
const parsed = parseDnsSdResolve(resolved.stdout, instance);
|
||||
if (parsed) results.push({ ...parsed, domain });
|
||||
}
|
||||
@@ -247,10 +240,7 @@ async function discoverWideAreaViaTailnetDns(
|
||||
const startedAt = Date.now();
|
||||
const remainingMs = () => timeoutMs - (Date.now() - startedAt);
|
||||
|
||||
const tailscaleCandidates = [
|
||||
"tailscale",
|
||||
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
|
||||
];
|
||||
const tailscaleCandidates = ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"];
|
||||
let ips: string[] = [];
|
||||
for (const candidate of tailscaleCandidates) {
|
||||
try {
|
||||
@@ -301,9 +291,7 @@ async function discoverWideAreaViaTailnetDns(
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(concurrency, ips.length) }, () => worker()),
|
||||
);
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, ips.length) }, () => worker()));
|
||||
|
||||
if (!nameserver || ptrs.length === 0) return [];
|
||||
if (remainingMs() <= 0) return [];
|
||||
@@ -317,10 +305,9 @@ async function discoverWideAreaViaTailnetDns(
|
||||
if (!ptrName) continue;
|
||||
const instanceName = ptrName.replace(/\.?_clawdbot-bridge\._tcp\..*$/, "");
|
||||
|
||||
const srv = await run(
|
||||
["dig", "+short", "+time=1", "+tries=1", nameserverArg, ptrName, "SRV"],
|
||||
{ timeoutMs: Math.max(1, Math.min(350, budget)) },
|
||||
).catch(() => null);
|
||||
const srv = await run(["dig", "+short", "+time=1", "+tries=1", nameserverArg, ptrName, "SRV"], {
|
||||
timeoutMs: Math.max(1, Math.min(350, budget)),
|
||||
}).catch(() => null);
|
||||
const srvParsed = srv ? parseDigSrv(srv.stdout) : null;
|
||||
if (!srvParsed) continue;
|
||||
|
||||
@@ -336,10 +323,9 @@ async function discoverWideAreaViaTailnetDns(
|
||||
continue;
|
||||
}
|
||||
|
||||
const txt = await run(
|
||||
["dig", "+short", "+time=1", "+tries=1", nameserverArg, ptrName, "TXT"],
|
||||
{ timeoutMs: Math.max(1, Math.min(350, txtBudget)) },
|
||||
).catch(() => null);
|
||||
const txt = await run(["dig", "+short", "+time=1", "+tries=1", nameserverArg, ptrName, "TXT"], {
|
||||
timeoutMs: Math.max(1, Math.min(350, txtBudget)),
|
||||
}).catch(() => null);
|
||||
const txtTokens = txt ? parseDigTxt(txt.stdout) : [];
|
||||
const txtMap = txtTokens.length > 0 ? parseTxtTokens(txtTokens) : {};
|
||||
|
||||
@@ -449,18 +435,12 @@ export async function discoverGatewayBeacons(
|
||||
try {
|
||||
if (platform === "darwin") {
|
||||
const perDomain = await Promise.allSettled(
|
||||
domains.map(
|
||||
async (domain) => await discoverViaDnsSd(domain, timeoutMs, run),
|
||||
),
|
||||
);
|
||||
const discovered = perDomain.flatMap((r) =>
|
||||
r.status === "fulfilled" ? r.value : [],
|
||||
domains.map(async (domain) => await discoverViaDnsSd(domain, timeoutMs, run)),
|
||||
);
|
||||
const discovered = perDomain.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
|
||||
|
||||
const wantsWideArea = domains.includes(WIDE_AREA_DISCOVERY_DOMAIN);
|
||||
const hasWideArea = discovered.some(
|
||||
(b) => b.domain === WIDE_AREA_DISCOVERY_DOMAIN,
|
||||
);
|
||||
const hasWideArea = discovered.some((b) => b.domain === WIDE_AREA_DISCOVERY_DOMAIN);
|
||||
|
||||
if (wantsWideArea && !hasWideArea) {
|
||||
const fallback = await discoverWideAreaViaTailnetDns(
|
||||
@@ -475,13 +455,9 @@ export async function discoverGatewayBeacons(
|
||||
}
|
||||
if (platform === "linux") {
|
||||
const perDomain = await Promise.allSettled(
|
||||
domains.map(
|
||||
async (domain) => await discoverViaAvahi(domain, timeoutMs, run),
|
||||
),
|
||||
);
|
||||
return perDomain.flatMap((r) =>
|
||||
r.status === "fulfilled" ? r.value : [],
|
||||
domains.map(async (domain) => await discoverViaAvahi(domain, timeoutMs, run)),
|
||||
);
|
||||
return perDomain.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
|
||||
@@ -41,9 +41,8 @@ vi.mock("@homebridge/ciao", () => {
|
||||
|
||||
vi.mock("./unhandled-rejections.js", () => {
|
||||
return {
|
||||
registerUnhandledRejectionHandler: (
|
||||
handler: (reason: unknown) => boolean,
|
||||
) => registerUnhandledRejectionHandler(handler),
|
||||
registerUnhandledRejectionHandler: (handler: (reason: unknown) => boolean) =>
|
||||
registerUnhandledRejectionHandler(handler),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -98,8 +97,7 @@ describe("gateway bonjour advertiser", () => {
|
||||
destroy,
|
||||
serviceState: "announced",
|
||||
on: vi.fn(),
|
||||
getFQDN: () =>
|
||||
`${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
|
||||
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
|
||||
getHostname: () => asString(options.hostname, "unknown"),
|
||||
getPort: () => Number(options.port ?? -1),
|
||||
};
|
||||
@@ -114,28 +112,18 @@ describe("gateway bonjour advertiser", () => {
|
||||
});
|
||||
|
||||
expect(createService).toHaveBeenCalledTimes(1);
|
||||
const [bridgeCall] = createService.mock.calls as Array<
|
||||
[Record<string, unknown>]
|
||||
>;
|
||||
const [bridgeCall] = createService.mock.calls as Array<[Record<string, unknown>]>;
|
||||
expect(bridgeCall?.[0]?.type).toBe("clawdbot-bridge");
|
||||
expect(bridgeCall?.[0]?.port).toBe(18790);
|
||||
expect(bridgeCall?.[0]?.domain).toBe("local");
|
||||
expect(bridgeCall?.[0]?.hostname).toBe("test-host");
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe(
|
||||
"test-host.local",
|
||||
);
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.bridgePort).toBe(
|
||||
"18790",
|
||||
);
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe(
|
||||
"2222",
|
||||
);
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("test-host.local");
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.bridgePort).toBe("18790");
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe("2222");
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.cliPath).toBe(
|
||||
"/opt/homebrew/bin/clawdbot",
|
||||
);
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.transport).toBe(
|
||||
"bridge",
|
||||
);
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.transport).toBe("bridge");
|
||||
|
||||
// We don't await `advertise()`, but it should still be called for each service.
|
||||
expect(advertise).toHaveBeenCalledTimes(1);
|
||||
@@ -165,8 +153,7 @@ describe("gateway bonjour advertiser", () => {
|
||||
destroy,
|
||||
serviceState: "announced",
|
||||
on,
|
||||
getFQDN: () =>
|
||||
`${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
|
||||
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
|
||||
getHostname: () => asString(options.hostname, "unknown"),
|
||||
getPort: () => Number(options.port ?? -1),
|
||||
};
|
||||
@@ -179,10 +166,7 @@ describe("gateway bonjour advertiser", () => {
|
||||
});
|
||||
|
||||
// 1 service × 2 listeners
|
||||
expect(onCalls.map((c) => c.event)).toEqual([
|
||||
"name-change",
|
||||
"hostname-change",
|
||||
]);
|
||||
expect(onCalls.map((c) => c.event)).toEqual(["name-change", "hostname-change"]);
|
||||
|
||||
await started.stop();
|
||||
});
|
||||
@@ -207,8 +191,7 @@ describe("gateway bonjour advertiser", () => {
|
||||
destroy,
|
||||
serviceState: "announced",
|
||||
on: vi.fn(),
|
||||
getFQDN: () =>
|
||||
`${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
|
||||
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
|
||||
getHostname: () => asString(options.hostname, "unknown"),
|
||||
getPort: () => Number(options.port ?? -1),
|
||||
};
|
||||
@@ -252,8 +235,7 @@ describe("gateway bonjour advertiser", () => {
|
||||
destroy,
|
||||
serviceState: "unannounced",
|
||||
on: vi.fn(),
|
||||
getFQDN: () =>
|
||||
`${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
|
||||
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
|
||||
getHostname: () => asString(options.hostname, "unknown"),
|
||||
getPort: () => Number(options.port ?? -1),
|
||||
};
|
||||
@@ -270,9 +252,7 @@ describe("gateway bonjour advertiser", () => {
|
||||
|
||||
// allow promise rejection handler to run
|
||||
await Promise.resolve();
|
||||
expect(logWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("advertise failed"),
|
||||
);
|
||||
expect(logWarn).toHaveBeenCalledWith(expect.stringContaining("advertise failed"));
|
||||
|
||||
// watchdog should attempt re-advertise at the 60s interval tick
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
@@ -302,8 +282,7 @@ describe("gateway bonjour advertiser", () => {
|
||||
destroy,
|
||||
serviceState: "unannounced",
|
||||
on: vi.fn(),
|
||||
getFQDN: () =>
|
||||
`${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
|
||||
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
|
||||
getHostname: () => asString(options.hostname, "unknown"),
|
||||
getPort: () => Number(options.port ?? -1),
|
||||
};
|
||||
@@ -316,9 +295,7 @@ describe("gateway bonjour advertiser", () => {
|
||||
});
|
||||
|
||||
expect(advertise).toHaveBeenCalledTimes(1);
|
||||
expect(logWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("advertise threw"),
|
||||
);
|
||||
expect(logWarn).toHaveBeenCalledWith(expect.stringContaining("advertise threw"));
|
||||
|
||||
await started.stop();
|
||||
});
|
||||
@@ -338,8 +315,7 @@ describe("gateway bonjour advertiser", () => {
|
||||
destroy,
|
||||
serviceState: "announced",
|
||||
on: vi.fn(),
|
||||
getFQDN: () =>
|
||||
`${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
|
||||
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
|
||||
getHostname: () => asString(options.hostname, "unknown"),
|
||||
getPort: () => Number(options.port ?? -1),
|
||||
};
|
||||
@@ -355,9 +331,7 @@ describe("gateway bonjour advertiser", () => {
|
||||
expect(bridgeCall?.[0]?.name).toBe("Mac (Clawdbot)");
|
||||
expect(bridgeCall?.[0]?.domain).toBe("local");
|
||||
expect(bridgeCall?.[0]?.hostname).toBe("Mac");
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe(
|
||||
"Mac.local",
|
||||
);
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("Mac.local");
|
||||
|
||||
await started.stop();
|
||||
});
|
||||
|
||||
@@ -66,8 +66,7 @@ function serviceSummary(label: string, svc: BonjourService): string {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const state =
|
||||
typeof svc.serviceState === "string" ? svc.serviceState : "unknown";
|
||||
const state = typeof svc.serviceState === "string" ? svc.serviceState : "unknown";
|
||||
return `${label} fqdn=${fqdn} host=${hostname} port=${port} state=${state}`;
|
||||
}
|
||||
|
||||
@@ -157,23 +156,16 @@ export async function startGatewayBonjourAdvertiser(
|
||||
try {
|
||||
svc.on("name-change", (name: unknown) => {
|
||||
const next = typeof name === "string" ? name : String(name);
|
||||
logWarn(
|
||||
`bonjour: ${label} name conflict resolved; newName=${JSON.stringify(next)}`,
|
||||
);
|
||||
logWarn(`bonjour: ${label} name conflict resolved; newName=${JSON.stringify(next)}`);
|
||||
});
|
||||
svc.on("hostname-change", (nextHostname: unknown) => {
|
||||
const next =
|
||||
typeof nextHostname === "string"
|
||||
? nextHostname
|
||||
: String(nextHostname);
|
||||
const next = typeof nextHostname === "string" ? nextHostname : String(nextHostname);
|
||||
logWarn(
|
||||
`bonjour: ${label} hostname conflict resolved; newHostname=${JSON.stringify(next)}`,
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
logDebug(
|
||||
`bonjour: failed to attach listeners for ${label}: ${String(err)}`,
|
||||
);
|
||||
logDebug(`bonjour: failed to attach listeners for ${label}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,8 +199,7 @@ export async function startGatewayBonjourAdvertiser(
|
||||
for (const { label, svc } of services) {
|
||||
const stateUnknown = (svc as { serviceState?: unknown }).serviceState;
|
||||
if (typeof stateUnknown !== "string") continue;
|
||||
if (stateUnknown === "announced" || stateUnknown === "announcing")
|
||||
continue;
|
||||
if (stateUnknown === "announced" || stateUnknown === "announcing") continue;
|
||||
|
||||
let key = label;
|
||||
try {
|
||||
|
||||
@@ -33,10 +33,7 @@ export function resolveBrewPathDirs(opts?: {
|
||||
// Linuxbrew defaults.
|
||||
dirs.push(path.join(homeDir, ".linuxbrew", "bin"));
|
||||
dirs.push(path.join(homeDir, ".linuxbrew", "sbin"));
|
||||
dirs.push(
|
||||
"/home/linuxbrew/.linuxbrew/bin",
|
||||
"/home/linuxbrew/.linuxbrew/sbin",
|
||||
);
|
||||
dirs.push("/home/linuxbrew/.linuxbrew/bin", "/home/linuxbrew/.linuxbrew/sbin");
|
||||
|
||||
// macOS defaults (also used by some Linux setups).
|
||||
dirs.push("/opt/homebrew/bin", "/usr/local/bin");
|
||||
|
||||
@@ -62,8 +62,7 @@ describe("node bridge server", () => {
|
||||
const ifaces = os.networkInterfaces();
|
||||
for (const entries of Object.values(ifaces)) {
|
||||
for (const info of entries ?? []) {
|
||||
if (info.family === "IPv4" && info.internal === false)
|
||||
return info.address;
|
||||
if (info.family === "IPv4" && info.internal === false) return info.address;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -231,8 +230,7 @@ describe("node bridge server", () => {
|
||||
});
|
||||
|
||||
it("handles req/res RPC after authentication", async () => {
|
||||
let lastRequest: { nodeId?: string; id?: string; method?: string } | null =
|
||||
null;
|
||||
let lastRequest: { nodeId?: string; id?: string; method?: string } | null = null;
|
||||
|
||||
const server = await startNodeBridgeServer({
|
||||
host: "127.0.0.1",
|
||||
@@ -386,10 +384,9 @@ describe("node bridge server", () => {
|
||||
const line3 = JSON.parse(await readLine2()) as { type: string };
|
||||
expect(line3.type).toBe("hello-ok");
|
||||
|
||||
await pollUntil(
|
||||
async () => (lastAuthed?.nodeId === "n4" ? lastAuthed : null),
|
||||
{ timeoutMs: 3000 },
|
||||
);
|
||||
await pollUntil(async () => (lastAuthed?.nodeId === "n4" ? lastAuthed : null), {
|
||||
timeoutMs: 3000,
|
||||
});
|
||||
|
||||
expect(lastAuthed?.nodeId).toBe("n4");
|
||||
// Prefer paired metadata over hello payload (token verifies the stored node record).
|
||||
|
||||
@@ -62,8 +62,7 @@ describe("node bridge server", () => {
|
||||
const ifaces = os.networkInterfaces();
|
||||
for (const entries of Object.values(ifaces)) {
|
||||
for (const info of entries ?? []) {
|
||||
if (info.family === "IPv4" && info.internal === false)
|
||||
return info.address;
|
||||
if (info.family === "IPv4" && info.internal === false) return info.address;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -206,21 +205,13 @@ describe("node bridge server", () => {
|
||||
expect(node?.deviceFamily).toBe("iPad");
|
||||
expect(node?.modelIdentifier).toBe("iPad14,5");
|
||||
expect(node?.caps).toEqual(["canvas", "camera"]);
|
||||
expect(node?.commands).toEqual([
|
||||
"canvas.eval",
|
||||
"canvas.snapshot",
|
||||
"camera.snap",
|
||||
]);
|
||||
expect(node?.commands).toEqual(["canvas.eval", "canvas.snapshot", "camera.snap"]);
|
||||
expect(node?.permissions).toEqual({ accessibility: true });
|
||||
|
||||
const after = await listNodePairing(baseDir);
|
||||
const paired = after.paired.find((p) => p.nodeId === "n-caps");
|
||||
expect(paired?.caps).toEqual(["canvas", "camera"]);
|
||||
expect(paired?.commands).toEqual([
|
||||
"canvas.eval",
|
||||
"canvas.snapshot",
|
||||
"camera.snap",
|
||||
]);
|
||||
expect(paired?.commands).toEqual(["canvas.eval", "canvas.snapshot", "camera.snap"]);
|
||||
expect(paired?.permissions).toEqual({ accessibility: true });
|
||||
|
||||
socket.destroy();
|
||||
|
||||
@@ -101,20 +101,15 @@ export function createNodeBridgeConnectionHandler(params: {
|
||||
const family = String(frame.deviceFamily ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (platform.includes("ios") || platform.includes("ipados"))
|
||||
return ["canvas", "camera"];
|
||||
if (platform.includes("ios") || platform.includes("ipados")) return ["canvas", "camera"];
|
||||
if (platform.includes("android")) return ["canvas", "camera"];
|
||||
if (family === "ipad" || family === "iphone" || family === "ios")
|
||||
return ["canvas", "camera"];
|
||||
if (family === "ipad" || family === "iphone" || family === "ios") return ["canvas", "camera"];
|
||||
if (family === "android") return ["canvas", "camera"];
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizePermissions = (
|
||||
raw: unknown,
|
||||
): Record<string, boolean> | undefined => {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
||||
return undefined;
|
||||
const normalizePermissions = (raw: unknown): Record<string, boolean> | undefined => {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const entries = Object.entries(raw as Record<string, unknown>)
|
||||
.map(([key, value]) => [String(key).trim(), value === true] as const)
|
||||
.filter(([key]) => key.length > 0);
|
||||
@@ -136,11 +131,7 @@ export function createNodeBridgeConnectionHandler(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const verified = await verifyNodeToken(
|
||||
nodeId,
|
||||
token,
|
||||
opts.pairingBaseDir,
|
||||
);
|
||||
const verified = await verifyNodeToken(nodeId, token, opts.pairingBaseDir);
|
||||
if (!verified.ok || !verified.node) {
|
||||
sendError("UNAUTHORIZED", "invalid token");
|
||||
return;
|
||||
@@ -212,15 +203,11 @@ export function createNodeBridgeConnectionHandler(params: {
|
||||
requestId: string;
|
||||
nodeId: string;
|
||||
ts: number;
|
||||
}): Promise<
|
||||
{ ok: true; token: string } | { ok: false; reason: string }
|
||||
> => {
|
||||
}): Promise<{ ok: true; token: string } | { ok: false; reason: string }> => {
|
||||
const deadline = Date.now() + 5 * 60 * 1000;
|
||||
while (!abort.signal.aborted && Date.now() < deadline) {
|
||||
const list = await listNodePairing(opts.pairingBaseDir);
|
||||
const stillPending = list.pending.some(
|
||||
(p) => p.requestId === request.requestId,
|
||||
);
|
||||
const stillPending = list.pending.some((p) => p.requestId === request.requestId);
|
||||
if (stillPending) {
|
||||
await sleep(250);
|
||||
continue;
|
||||
@@ -301,9 +288,7 @@ export function createNodeBridgeConnectionHandler(params: {
|
||||
version: req.version,
|
||||
deviceFamily: req.deviceFamily,
|
||||
modelIdentifier: req.modelIdentifier,
|
||||
caps: Array.isArray(req.caps)
|
||||
? req.caps.map((c) => String(c)).filter(Boolean)
|
||||
: undefined,
|
||||
caps: Array.isArray(req.caps) ? req.caps.map((c) => String(c)).filter(Boolean) : undefined,
|
||||
commands: Array.isArray(req.commands)
|
||||
? req.commands.map((c) => String(c)).filter(Boolean)
|
||||
: undefined,
|
||||
|
||||
@@ -4,10 +4,7 @@ import os from "node:os";
|
||||
|
||||
import { resolveCanvasHostUrl } from "../../canvas-host-url.js";
|
||||
|
||||
import {
|
||||
type ConnectionState,
|
||||
createNodeBridgeConnectionHandler,
|
||||
} from "./connection.js";
|
||||
import { type ConnectionState, createNodeBridgeConnectionHandler } from "./connection.js";
|
||||
import { createDisabledNodeBridgeServer } from "./disabled.js";
|
||||
import { encodeLine } from "./encode.js";
|
||||
import { shouldAlsoListenOnLoopback } from "./loopback.js";
|
||||
@@ -20,13 +17,8 @@ import type {
|
||||
NodeBridgeServerOpts,
|
||||
} from "./types.js";
|
||||
|
||||
export async function startNodeBridgeServer(
|
||||
opts: NodeBridgeServerOpts,
|
||||
): Promise<NodeBridgeServer> {
|
||||
if (
|
||||
isNodeBridgeTestEnv() &&
|
||||
process.env.CLAWDBOT_ENABLE_BRIDGE_IN_TESTS !== "1"
|
||||
) {
|
||||
export async function startNodeBridgeServer(opts: NodeBridgeServerOpts): Promise<NodeBridgeServer> {
|
||||
if (isNodeBridgeTestEnv() && process.env.CLAWDBOT_ENABLE_BRIDGE_IN_TESTS !== "1") {
|
||||
return createDisabledNodeBridgeServer();
|
||||
}
|
||||
|
||||
@@ -70,8 +62,7 @@ export async function startNodeBridgeServer(
|
||||
});
|
||||
|
||||
const address = primary.address();
|
||||
const port =
|
||||
typeof address === "object" && address ? address.port : opts.port;
|
||||
const port = typeof address === "object" && address ? address.port : opts.port;
|
||||
|
||||
if (shouldAlsoListenOnLoopback(opts.host)) {
|
||||
const loopback = net.createServer(onConnection);
|
||||
@@ -137,16 +128,11 @@ export async function startNodeBridgeServer(
|
||||
invoke: async ({ nodeId, command, paramsJSON, timeoutMs }) => {
|
||||
const normalizedNodeId = String(nodeId ?? "").trim();
|
||||
const normalizedCommand = String(command ?? "").trim();
|
||||
if (!normalizedNodeId)
|
||||
throw new Error("INVALID_REQUEST: nodeId required");
|
||||
if (!normalizedCommand)
|
||||
throw new Error("INVALID_REQUEST: command required");
|
||||
if (!normalizedNodeId) throw new Error("INVALID_REQUEST: nodeId required");
|
||||
if (!normalizedCommand) throw new Error("INVALID_REQUEST: command required");
|
||||
|
||||
const conn = connections.get(normalizedNodeId);
|
||||
if (!conn)
|
||||
throw new Error(
|
||||
`UNAVAILABLE: node not connected (${normalizedNodeId})`,
|
||||
);
|
||||
if (!conn) throw new Error(`UNAVAILABLE: node not connected (${normalizedNodeId})`);
|
||||
|
||||
const id = randomUUID();
|
||||
const timeout = Number.isFinite(timeoutMs) ? Number(timeoutMs) : 15_000;
|
||||
|
||||
@@ -101,11 +101,7 @@ export type NodeBridgeServer = {
|
||||
paramsJSON?: string | null;
|
||||
timeoutMs?: number;
|
||||
}) => Promise<BridgeInvokeResponseFrame>;
|
||||
sendEvent: (opts: {
|
||||
nodeId: string;
|
||||
event: string;
|
||||
payloadJSON?: string | null;
|
||||
}) => void;
|
||||
sendEvent: (opts: { nodeId: string; event: string; payloadJSON?: string | null }) => void;
|
||||
listConnected: () => NodeBridgeClientInfo[];
|
||||
listeners: Array<{ host: string; port: number }>;
|
||||
};
|
||||
@@ -139,8 +135,6 @@ export type NodeBridgeServerOpts = {
|
||||
>;
|
||||
onAuthenticated?: (node: NodeBridgeClientInfo) => Promise<void> | void;
|
||||
onDisconnected?: (node: NodeBridgeClientInfo) => Promise<void> | void;
|
||||
onPairRequested?: (
|
||||
request: NodePairingPendingRequest,
|
||||
) => Promise<void> | void;
|
||||
onPairRequested?: (request: NodePairingPendingRequest) => Promise<void> | void;
|
||||
serverName?: string;
|
||||
};
|
||||
|
||||
@@ -46,19 +46,11 @@ export function resolveCanvasHostUrl(params: CanvasHostUrlParams) {
|
||||
|
||||
const scheme =
|
||||
params.scheme ??
|
||||
(parseForwardedProto(params.forwardedProto)?.trim() === "https"
|
||||
? "https"
|
||||
: "http");
|
||||
(parseForwardedProto(params.forwardedProto)?.trim() === "https" ? "https" : "http");
|
||||
|
||||
const override = normalizeHost(params.hostOverride, true);
|
||||
const requestHost = normalizeHost(
|
||||
parseHostHeader(params.requestHost),
|
||||
!!override,
|
||||
);
|
||||
const localAddress = normalizeHost(
|
||||
params.localAddress,
|
||||
Boolean(override || requestHost),
|
||||
);
|
||||
const requestHost = normalizeHost(parseHostHeader(params.requestHost), !!override);
|
||||
const localAddress = normalizeHost(params.localAddress, Boolean(override || requestHost));
|
||||
|
||||
const host = override || requestHost || localAddress;
|
||||
if (!host) return undefined;
|
||||
|
||||
@@ -39,17 +39,13 @@ describe("channel activity", () => {
|
||||
direction: "inbound",
|
||||
at: 2,
|
||||
});
|
||||
expect(getChannelActivity({ channel: "whatsapp", accountId: "a" })).toEqual(
|
||||
{
|
||||
inboundAt: 1,
|
||||
outboundAt: null,
|
||||
},
|
||||
);
|
||||
expect(getChannelActivity({ channel: "whatsapp", accountId: "b" })).toEqual(
|
||||
{
|
||||
inboundAt: 2,
|
||||
outboundAt: null,
|
||||
},
|
||||
);
|
||||
expect(getChannelActivity({ channel: "whatsapp", accountId: "a" })).toEqual({
|
||||
inboundAt: 1,
|
||||
outboundAt: null,
|
||||
});
|
||||
expect(getChannelActivity({ channel: "whatsapp", accountId: "b" })).toEqual({
|
||||
inboundAt: 2,
|
||||
outboundAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelPlugin,
|
||||
} from "../channels/plugins/types.js";
|
||||
import type { ChannelAccountSnapshot, ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import { type ClawdbotConfig, loadConfig } from "../config/config.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
@@ -144,21 +141,14 @@ export async function buildChannelSummary(
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
const accountIds = plugin.config.listAccountIds(effective);
|
||||
const defaultAccountId =
|
||||
plugin.config.defaultAccountId?.(effective) ??
|
||||
accountIds[0] ??
|
||||
DEFAULT_ACCOUNT_ID;
|
||||
const resolvedAccountIds =
|
||||
accountIds.length > 0 ? accountIds : [defaultAccountId];
|
||||
plugin.config.defaultAccountId?.(effective) ?? accountIds[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
const resolvedAccountIds = accountIds.length > 0 ? accountIds : [defaultAccountId];
|
||||
const entries: ChannelAccountEntry[] = [];
|
||||
|
||||
for (const accountId of resolvedAccountIds) {
|
||||
const account = plugin.config.resolveAccount(effective, accountId);
|
||||
const enabled = resolveAccountEnabled(plugin, account, effective);
|
||||
const configured = await resolveAccountConfigured(
|
||||
plugin,
|
||||
account,
|
||||
effective,
|
||||
);
|
||||
const configured = await resolveAccountConfigured(plugin, account, effective);
|
||||
const snapshot = buildAccountSnapshot({
|
||||
plugin,
|
||||
account,
|
||||
@@ -173,24 +163,20 @@ export async function buildChannelSummary(
|
||||
const configuredEntries = entries.filter((entry) => entry.configured);
|
||||
const anyEnabled = entries.some((entry) => entry.enabled);
|
||||
const fallbackEntry =
|
||||
entries.find((entry) => entry.accountId === defaultAccountId) ??
|
||||
entries[0];
|
||||
entries.find((entry) => entry.accountId === defaultAccountId) ?? entries[0];
|
||||
const summary = plugin.status?.buildChannelSummary
|
||||
? await plugin.status.buildChannelSummary({
|
||||
account: fallbackEntry?.account ?? {},
|
||||
cfg: effective,
|
||||
defaultAccountId,
|
||||
snapshot:
|
||||
fallbackEntry?.snapshot ??
|
||||
({ accountId: defaultAccountId } as ChannelAccountSnapshot),
|
||||
fallbackEntry?.snapshot ?? ({ accountId: defaultAccountId } as ChannelAccountSnapshot),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const summaryRecord = summary as Record<string, unknown> | undefined;
|
||||
const linked =
|
||||
summaryRecord && typeof summaryRecord.linked === "boolean"
|
||||
? summaryRecord.linked
|
||||
: null;
|
||||
summaryRecord && typeof summaryRecord.linked === "boolean" ? summaryRecord.linked : null;
|
||||
const configured =
|
||||
summaryRecord && typeof summaryRecord.configured === "boolean"
|
||||
? summaryRecord.configured
|
||||
@@ -216,9 +202,7 @@ export async function buildChannelSummary(
|
||||
let line = `${baseLabel}: ${status}`;
|
||||
|
||||
const authAgeMs =
|
||||
summaryRecord && typeof summaryRecord.authAgeMs === "number"
|
||||
? summaryRecord.authAgeMs
|
||||
: null;
|
||||
summaryRecord && typeof summaryRecord.authAgeMs === "number" ? summaryRecord.authAgeMs : null;
|
||||
const self = summaryRecord?.self as { e164?: string | null } | undefined;
|
||||
if (self?.e164) line += ` ${self.e164}`;
|
||||
if (authAgeMs != null && authAgeMs >= 0) {
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelStatusIssue,
|
||||
} from "../channels/plugins/types.js";
|
||||
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../channels/plugins/types.js";
|
||||
|
||||
export function collectChannelStatusIssues(
|
||||
payload: Record<string, unknown>,
|
||||
): ChannelStatusIssue[] {
|
||||
export function collectChannelStatusIssues(payload: Record<string, unknown>): ChannelStatusIssue[] {
|
||||
const issues: ChannelStatusIssue[] = [];
|
||||
const accountsByChannel = payload.channelAccounts as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const accountsByChannel = payload.channelAccounts as Record<string, unknown> | undefined;
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
const collect = plugin.status?.collectStatusIssues;
|
||||
if (!collect) continue;
|
||||
|
||||
@@ -12,10 +12,7 @@ async function readPackageName(dir: string): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
async function findPackageRoot(
|
||||
startDir: string,
|
||||
maxDepth = 12,
|
||||
): Promise<string | null> {
|
||||
async function findPackageRoot(startDir: string, maxDepth = 12): Promise<string | null> {
|
||||
let current = path.resolve(startDir);
|
||||
for (let i = 0; i < maxDepth; i += 1) {
|
||||
const name = await readPackageName(current);
|
||||
|
||||
@@ -4,27 +4,19 @@ import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
resolveControlUiDistIndexPath,
|
||||
resolveControlUiRepoRoot,
|
||||
} from "./control-ui-assets.js";
|
||||
import { resolveControlUiDistIndexPath, resolveControlUiRepoRoot } from "./control-ui-assets.js";
|
||||
|
||||
describe("control UI assets helpers", () => {
|
||||
it("resolves repo root from src argv1", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ui-"));
|
||||
try {
|
||||
await fs.mkdir(path.join(tmp, "ui"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(tmp, "ui", "vite.config.ts"),
|
||||
"export {};\n",
|
||||
);
|
||||
await fs.writeFile(path.join(tmp, "ui", "vite.config.ts"), "export {};\n");
|
||||
await fs.writeFile(path.join(tmp, "package.json"), "{}\n");
|
||||
await fs.mkdir(path.join(tmp, "src"), { recursive: true });
|
||||
await fs.writeFile(path.join(tmp, "src", "index.ts"), "export {};\n");
|
||||
|
||||
expect(resolveControlUiRepoRoot(path.join(tmp, "src", "index.ts"))).toBe(
|
||||
tmp,
|
||||
);
|
||||
expect(resolveControlUiRepoRoot(path.join(tmp, "src", "index.ts"))).toBe(tmp);
|
||||
} finally {
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
@@ -34,17 +26,12 @@ describe("control UI assets helpers", () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ui-"));
|
||||
try {
|
||||
await fs.mkdir(path.join(tmp, "ui"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(tmp, "ui", "vite.config.ts"),
|
||||
"export {};\n",
|
||||
);
|
||||
await fs.writeFile(path.join(tmp, "ui", "vite.config.ts"), "export {};\n");
|
||||
await fs.writeFile(path.join(tmp, "package.json"), "{}\n");
|
||||
await fs.mkdir(path.join(tmp, "dist"), { recursive: true });
|
||||
await fs.writeFile(path.join(tmp, "dist", "index.js"), "export {};\n");
|
||||
|
||||
expect(resolveControlUiRepoRoot(path.join(tmp, "dist", "index.js"))).toBe(
|
||||
tmp,
|
||||
);
|
||||
expect(resolveControlUiRepoRoot(path.join(tmp, "dist", "index.js"))).toBe(tmp);
|
||||
} finally {
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -94,17 +94,12 @@ export async function ensureControlUiAssetsBuilt(
|
||||
};
|
||||
}
|
||||
|
||||
runtime.log(
|
||||
"Control UI assets missing; building (ui:build, auto-installs UI deps)…",
|
||||
);
|
||||
runtime.log("Control UI assets missing; building (ui:build, auto-installs UI deps)…");
|
||||
|
||||
const build = await runCommandWithTimeout(
|
||||
[process.execPath, uiScript, "build"],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
timeoutMs: opts?.timeoutMs ?? 10 * 60_000,
|
||||
},
|
||||
);
|
||||
const build = await runCommandWithTimeout([process.execPath, uiScript, "build"], {
|
||||
cwd: repoRoot,
|
||||
timeoutMs: opts?.timeoutMs ?? 10 * 60_000,
|
||||
});
|
||||
if (build.code !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -16,9 +16,7 @@ describe("loadDotEnv", () => {
|
||||
const prevEnv = { ...process.env };
|
||||
const prevCwd = process.cwd();
|
||||
|
||||
const base = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-dotenv-test-"),
|
||||
);
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-dotenv-test-"));
|
||||
const cwdDir = path.join(base, "cwd");
|
||||
const stateDir = path.join(base, "state");
|
||||
|
||||
@@ -50,9 +48,7 @@ describe("loadDotEnv", () => {
|
||||
const prevEnv = { ...process.env };
|
||||
const prevCwd = process.cwd();
|
||||
|
||||
const base = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-dotenv-test-"),
|
||||
);
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-dotenv-test-"));
|
||||
const cwdDir = path.join(base, "cwd");
|
||||
const stateDir = path.join(base, "state");
|
||||
|
||||
|
||||
@@ -11,11 +11,7 @@ export function formatErrorMessage(err: unknown): string {
|
||||
return err.message || err.name || "Error";
|
||||
}
|
||||
if (typeof err === "string") return err;
|
||||
if (
|
||||
typeof err === "number" ||
|
||||
typeof err === "boolean" ||
|
||||
typeof err === "bigint"
|
||||
) {
|
||||
if (typeof err === "number" || typeof err === "boolean" || typeof err === "bigint") {
|
||||
return String(err);
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -9,9 +9,7 @@ function isLikelyPath(value: string): boolean {
|
||||
return /^[A-Za-z]:[\\/]/.test(value);
|
||||
}
|
||||
|
||||
export function isSafeExecutableValue(
|
||||
value: string | null | undefined,
|
||||
): boolean {
|
||||
export function isSafeExecutableValue(value: string | null | undefined): boolean {
|
||||
if (!value) return false;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
@@ -21,10 +21,7 @@ export type FormatDurationMsOptions = {
|
||||
unit?: "s" | "seconds";
|
||||
};
|
||||
|
||||
export function formatDurationMs(
|
||||
ms: number,
|
||||
options: FormatDurationMsOptions = {},
|
||||
): string {
|
||||
export function formatDurationMs(ms: number, options: FormatDurationMsOptions = {}): string {
|
||||
if (!Number.isFinite(ms)) return "unknown";
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return formatDurationSeconds(ms, {
|
||||
|
||||
@@ -51,9 +51,7 @@ const readCommitFromPackageJson = () => {
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveCommitHash = (
|
||||
options: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
|
||||
) => {
|
||||
export const resolveCommitHash = (options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) => {
|
||||
if (cachedCommit !== undefined) return cachedCommit;
|
||||
const env = options.env ?? process.env;
|
||||
const envCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim();
|
||||
|
||||
@@ -23,9 +23,7 @@ export function emitHeartbeatEvent(evt: Omit<HeartbeatEventPayload, "ts">) {
|
||||
}
|
||||
}
|
||||
|
||||
export function onHeartbeatEvent(
|
||||
listener: (evt: HeartbeatEventPayload) => void,
|
||||
): () => void {
|
||||
export function onHeartbeatEvent(listener: (evt: HeartbeatEventPayload) => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
@@ -217,10 +217,7 @@ describe("runHeartbeatOnce", () => {
|
||||
session: { store: storePath },
|
||||
};
|
||||
|
||||
replySpy.mockResolvedValue([
|
||||
{ text: "Let me check..." },
|
||||
{ text: "Final alert" },
|
||||
]);
|
||||
replySpy.mockResolvedValue([{ text: "Let me check..." }, { text: "Final alert" }]);
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
@@ -238,11 +235,7 @@ describe("runHeartbeatOnce", () => {
|
||||
});
|
||||
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
||||
expect(sendWhatsApp).toHaveBeenCalledWith(
|
||||
"+1555",
|
||||
"Final alert",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "Final alert", expect.any(Object));
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
@@ -312,12 +305,7 @@ describe("runHeartbeatOnce", () => {
|
||||
"Reasoning:\n_Because it helps_",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(sendWhatsApp).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"+1555",
|
||||
"Final alert",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(sendWhatsApp).toHaveBeenNthCalledWith(2, "+1555", "Final alert", expect.any(Object));
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
@@ -395,12 +383,7 @@ describe("runHeartbeatOnce", () => {
|
||||
|
||||
it("loads the default agent session from templated stores", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storeTemplate = path.join(
|
||||
tmpDir,
|
||||
"agents",
|
||||
"{agentId}",
|
||||
"sessions.json",
|
||||
);
|
||||
const storeTemplate = path.join(tmpDir, "agents", "{agentId}", "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
const cfg: ClawdbotConfig = {
|
||||
|
||||
@@ -7,10 +7,7 @@ import {
|
||||
} from "../auto-reply/heartbeat.js";
|
||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import {
|
||||
getChannelPlugin,
|
||||
normalizeChannelId,
|
||||
} from "../channels/plugins/index.js";
|
||||
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import type { ChannelHeartbeatDeps } from "../channels/plugins/types.js";
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
@@ -51,14 +48,8 @@ export function setHeartbeatsEnabled(enabled: boolean) {
|
||||
heartbeatsEnabled = enabled;
|
||||
}
|
||||
|
||||
export function resolveHeartbeatIntervalMs(
|
||||
cfg: ClawdbotConfig,
|
||||
overrideEvery?: string,
|
||||
) {
|
||||
const raw =
|
||||
overrideEvery ??
|
||||
cfg.agents?.defaults?.heartbeat?.every ??
|
||||
DEFAULT_HEARTBEAT_EVERY;
|
||||
export function resolveHeartbeatIntervalMs(cfg: ClawdbotConfig, overrideEvery?: string) {
|
||||
const raw = overrideEvery ?? cfg.agents?.defaults?.heartbeat?.every ?? DEFAULT_HEARTBEAT_EVERY;
|
||||
if (!raw) return null;
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -79,8 +70,7 @@ export function resolveHeartbeatPrompt(cfg: ClawdbotConfig) {
|
||||
function resolveHeartbeatAckMaxChars(cfg: ClawdbotConfig) {
|
||||
return Math.max(
|
||||
0,
|
||||
cfg.agents?.defaults?.heartbeat?.ackMaxChars ??
|
||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,11 +93,7 @@ function resolveHeartbeatReplyPayload(
|
||||
for (let idx = replyResult.length - 1; idx >= 0; idx -= 1) {
|
||||
const payload = replyResult[idx];
|
||||
if (!payload) continue;
|
||||
if (
|
||||
payload.text ||
|
||||
payload.mediaUrl ||
|
||||
(payload.mediaUrls && payload.mediaUrls.length > 0)
|
||||
) {
|
||||
if (payload.text || payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length > 0)) {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@@ -117,11 +103,7 @@ function resolveHeartbeatReplyPayload(
|
||||
function resolveHeartbeatReasoningPayloads(
|
||||
replyResult: ReplyPayload | ReplyPayload[] | undefined,
|
||||
): ReplyPayload[] {
|
||||
const payloads = Array.isArray(replyResult)
|
||||
? replyResult
|
||||
: replyResult
|
||||
? [replyResult]
|
||||
: [];
|
||||
const payloads = Array.isArray(replyResult) ? replyResult : replyResult ? [replyResult] : [];
|
||||
return payloads.filter((payload) => {
|
||||
const text = typeof payload.text === "string" ? payload.text : "";
|
||||
return text.trimStart().startsWith("Reasoning:");
|
||||
@@ -146,9 +128,7 @@ function resolveHeartbeatSender(params: {
|
||||
return candidates[0] ?? "heartbeat";
|
||||
}
|
||||
if (candidates.length > 0 && allowList.length > 0) {
|
||||
const matched = candidates.find((candidate) =>
|
||||
allowList.includes(candidate),
|
||||
);
|
||||
const matched = candidates.find((candidate) => allowList.includes(candidate));
|
||||
if (matched) return matched;
|
||||
}
|
||||
if (candidates.length > 0 && allowList.length === 0) {
|
||||
@@ -182,9 +162,7 @@ function normalizeHeartbeatReply(
|
||||
mode: "heartbeat",
|
||||
maxAckChars: ackMaxChars,
|
||||
});
|
||||
const hasMedia = Boolean(
|
||||
payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0,
|
||||
);
|
||||
const hasMedia = Boolean(payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0);
|
||||
if (stripped.shouldSkip && !hasMedia) {
|
||||
return {
|
||||
shouldSkip: true,
|
||||
@@ -225,13 +203,11 @@ export async function runHeartbeatOnce(opts: {
|
||||
entry?.lastChannel && entry.lastChannel !== INTERNAL_MESSAGE_CHANNEL
|
||||
? normalizeChannelId(entry.lastChannel)
|
||||
: undefined;
|
||||
const senderProvider =
|
||||
delivery.channel !== "none" ? delivery.channel : lastChannel;
|
||||
const senderProvider = delivery.channel !== "none" ? delivery.channel : lastChannel;
|
||||
const senderAllowFrom = senderProvider
|
||||
? (getChannelPlugin(senderProvider)?.config.resolveAllowFrom?.({
|
||||
cfg,
|
||||
accountId:
|
||||
senderProvider === lastChannel ? entry?.lastAccountId : undefined,
|
||||
accountId: senderProvider === lastChannel ? entry?.lastAccountId : undefined,
|
||||
}) ?? [])
|
||||
: [];
|
||||
const sender = resolveHeartbeatSender({
|
||||
@@ -248,25 +224,16 @@ export async function runHeartbeatOnce(opts: {
|
||||
};
|
||||
|
||||
try {
|
||||
const replyResult = await getReplyFromConfig(
|
||||
ctx,
|
||||
{ isHeartbeat: true },
|
||||
cfg,
|
||||
);
|
||||
const replyResult = await getReplyFromConfig(ctx, { isHeartbeat: true }, cfg);
|
||||
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
|
||||
const includeReasoning =
|
||||
cfg.agents?.defaults?.heartbeat?.includeReasoning === true;
|
||||
const includeReasoning = cfg.agents?.defaults?.heartbeat?.includeReasoning === true;
|
||||
const reasoningPayloads = includeReasoning
|
||||
? resolveHeartbeatReasoningPayloads(replyResult).filter(
|
||||
(payload) => payload !== replyPayload,
|
||||
)
|
||||
? resolveHeartbeatReasoningPayloads(replyResult).filter((payload) => payload !== replyPayload)
|
||||
: [];
|
||||
|
||||
if (
|
||||
!replyPayload ||
|
||||
(!replyPayload.text &&
|
||||
!replyPayload.mediaUrl &&
|
||||
!replyPayload.mediaUrls?.length)
|
||||
(!replyPayload.text && !replyPayload.mediaUrl && !replyPayload.mediaUrls?.length)
|
||||
) {
|
||||
await restoreHeartbeatUpdatedAt({
|
||||
storePath,
|
||||
@@ -284,10 +251,7 @@ export async function runHeartbeatOnce(opts: {
|
||||
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg);
|
||||
const normalized = normalizeHeartbeatReply(
|
||||
replyPayload,
|
||||
resolveEffectiveMessagesConfig(
|
||||
cfg,
|
||||
resolveAgentIdFromSessionKey(sessionKey),
|
||||
).responsePrefix,
|
||||
resolveEffectiveMessagesConfig(cfg, resolveAgentIdFromSessionKey(sessionKey)).responsePrefix,
|
||||
ackMaxChars,
|
||||
);
|
||||
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia;
|
||||
@@ -306,8 +270,7 @@ export async function runHeartbeatOnce(opts: {
|
||||
}
|
||||
|
||||
const mediaUrls =
|
||||
replyPayload.mediaUrls ??
|
||||
(replyPayload.mediaUrl ? [replyPayload.mediaUrl] : []);
|
||||
replyPayload.mediaUrls ?? (replyPayload.mediaUrl ? [replyPayload.mediaUrl] : []);
|
||||
// Reasoning payloads are text-only; any attachments stay on the main reply.
|
||||
const previewText = shouldSkipMain
|
||||
? reasoningPayloads
|
||||
@@ -327,8 +290,7 @@ export async function runHeartbeatOnce(opts: {
|
||||
return { status: "ran", durationMs: Date.now() - startedAt };
|
||||
}
|
||||
|
||||
const deliveryAccountId =
|
||||
delivery.channel === lastChannel ? entry?.lastAccountId : undefined;
|
||||
const deliveryAccountId = delivery.channel === lastChannel ? entry?.lastAccountId : undefined;
|
||||
const heartbeatPlugin = getChannelPlugin(delivery.channel);
|
||||
if (heartbeatPlugin?.heartbeat?.checkReady) {
|
||||
const readiness = await heartbeatPlugin.heartbeat.checkReady({
|
||||
|
||||
@@ -3,9 +3,7 @@ export type HeartbeatRunResult =
|
||||
| { status: "skipped"; reason: string }
|
||||
| { status: "failed"; reason: string };
|
||||
|
||||
export type HeartbeatWakeHandler = (opts: {
|
||||
reason?: string;
|
||||
}) => Promise<HeartbeatRunResult>;
|
||||
export type HeartbeatWakeHandler = (opts: { reason?: string }) => Promise<HeartbeatRunResult>;
|
||||
|
||||
let handler: HeartbeatWakeHandler | null = null;
|
||||
let pendingReason: string | null = null;
|
||||
@@ -58,10 +56,7 @@ export function setHeartbeatWakeHandler(next: HeartbeatWakeHandler | null) {
|
||||
}
|
||||
}
|
||||
|
||||
export function requestHeartbeatNow(opts?: {
|
||||
reason?: string;
|
||||
coalesceMs?: number;
|
||||
}) {
|
||||
export function requestHeartbeatNow(opts?: { reason?: string; coalesceMs?: number }) {
|
||||
pendingReason = opts?.reason ?? pendingReason ?? "requested";
|
||||
schedule(opts?.coalesceMs ?? DEFAULT_COALESCE_MS);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,7 @@ type IsMainModuleOptions = {
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
function normalizePathCandidate(
|
||||
candidate: string | undefined,
|
||||
cwd: string,
|
||||
): string | undefined {
|
||||
function normalizePathCandidate(candidate: string | undefined, cwd: string): string | undefined {
|
||||
if (!candidate) return undefined;
|
||||
|
||||
const resolved = path.resolve(cwd, candidate);
|
||||
@@ -31,22 +28,14 @@ export function isMainModule({
|
||||
const normalizedCurrent = normalizePathCandidate(currentFile, cwd);
|
||||
const normalizedArgv1 = normalizePathCandidate(argv[1], cwd);
|
||||
|
||||
if (
|
||||
normalizedCurrent &&
|
||||
normalizedArgv1 &&
|
||||
normalizedCurrent === normalizedArgv1
|
||||
) {
|
||||
if (normalizedCurrent && normalizedArgv1 && normalizedCurrent === normalizedArgv1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// PM2 runs the script via an internal wrapper; `argv[1]` points at the wrapper.
|
||||
// PM2 exposes the actual script path in `pm_exec_path`.
|
||||
const normalizedPmExecPath = normalizePathCandidate(env.pm_exec_path, cwd);
|
||||
if (
|
||||
normalizedCurrent &&
|
||||
normalizedPmExecPath &&
|
||||
normalizedCurrent === normalizedPmExecPath
|
||||
) {
|
||||
if (normalizedCurrent && normalizedPmExecPath && normalizedCurrent === normalizedPmExecPath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -131,9 +131,7 @@ function newToken() {
|
||||
return randomUUID().replaceAll("-", "");
|
||||
}
|
||||
|
||||
export async function listNodePairing(
|
||||
baseDir?: string,
|
||||
): Promise<NodePairingList> {
|
||||
export async function listNodePairing(baseDir?: string): Promise<NodePairingList> {
|
||||
const state = await loadState(baseDir);
|
||||
const pending = Object.values(state.pendingById).sort((a, b) => b.ts - a.ts);
|
||||
const paired = Object.values(state.pairedByNodeId).sort(
|
||||
@@ -165,9 +163,7 @@ export async function requestNodePairing(
|
||||
throw new Error("nodeId required");
|
||||
}
|
||||
|
||||
const existing = Object.values(state.pendingById).find(
|
||||
(p) => p.nodeId === nodeId,
|
||||
);
|
||||
const existing = Object.values(state.pendingById).find((p) => p.nodeId === nodeId);
|
||||
if (existing) {
|
||||
return { status: "pending", request: existing, created: false };
|
||||
}
|
||||
@@ -257,12 +253,7 @@ export async function verifyNodeToken(
|
||||
|
||||
export async function updatePairedNodeMetadata(
|
||||
nodeId: string,
|
||||
patch: Partial<
|
||||
Omit<
|
||||
NodePairingPairedNode,
|
||||
"nodeId" | "token" | "createdAtMs" | "approvedAtMs"
|
||||
>
|
||||
>,
|
||||
patch: Partial<Omit<NodePairingPairedNode, "nodeId" | "token" | "createdAtMs" | "approvedAtMs">>,
|
||||
baseDir?: string,
|
||||
) {
|
||||
await withLock(async () => {
|
||||
|
||||
@@ -21,10 +21,7 @@ function isAccountEnabled(account: unknown): boolean {
|
||||
return enabled !== false;
|
||||
}
|
||||
|
||||
async function isPluginConfigured(
|
||||
plugin: ChannelPlugin,
|
||||
cfg: ClawdbotConfig,
|
||||
): Promise<boolean> {
|
||||
async function isPluginConfigured(plugin: ChannelPlugin, cfg: ClawdbotConfig): Promise<boolean> {
|
||||
const accountIds = plugin.config.listAccountIds(cfg);
|
||||
if (accountIds.length === 0) return false;
|
||||
|
||||
@@ -78,8 +75,6 @@ export async function resolveMessageChannelSelection(params: {
|
||||
throw new Error("Channel is required (no configured channels detected).");
|
||||
}
|
||||
throw new Error(
|
||||
`Channel is required when multiple channels are configured: ${configured.join(
|
||||
", ",
|
||||
)}`,
|
||||
`Channel is required when multiple channels are configured: ${configured.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
deliverOutboundPayloads,
|
||||
normalizeOutboundPayloads,
|
||||
} from "./deliver.js";
|
||||
import { deliverOutboundPayloads, normalizeOutboundPayloads } from "./deliver.js";
|
||||
|
||||
describe("deliverOutboundPayloads", () => {
|
||||
it("chunks telegram markdown and passes through accountId", async () => {
|
||||
const sendTelegram = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: { telegram: { botToken: "tok-1", textChunkLimit: 2 } },
|
||||
};
|
||||
@@ -27,9 +22,7 @@ describe("deliverOutboundPayloads", () => {
|
||||
|
||||
expect(sendTelegram).toHaveBeenCalledTimes(2);
|
||||
for (const call of sendTelegram.mock.calls) {
|
||||
expect(call[2]).toEqual(
|
||||
expect.objectContaining({ accountId: undefined, verbose: false }),
|
||||
);
|
||||
expect(call[2]).toEqual(expect.objectContaining({ accountId: undefined, verbose: false }));
|
||||
}
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toMatchObject({ channel: "telegram", chatId: "c1" });
|
||||
@@ -43,9 +36,7 @@ describe("deliverOutboundPayloads", () => {
|
||||
});
|
||||
|
||||
it("passes explicit accountId to sendTelegram", async () => {
|
||||
const sendTelegram = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: { telegram: { botToken: "tok-1", textChunkLimit: 2 } },
|
||||
};
|
||||
@@ -67,9 +58,7 @@ describe("deliverOutboundPayloads", () => {
|
||||
});
|
||||
|
||||
it("uses signal media maxBytes from config", async () => {
|
||||
const sendSignal = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ messageId: "s1", timestamp: 123 });
|
||||
const sendSignal = vi.fn().mockResolvedValue({ messageId: "s1", timestamp: 123 });
|
||||
const cfg: ClawdbotConfig = { channels: { signal: { mediaMaxMb: 2 } } };
|
||||
|
||||
const results = await deliverOutboundPayloads({
|
||||
@@ -166,8 +155,6 @@ describe("deliverOutboundPayloads", () => {
|
||||
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(results).toEqual([
|
||||
{ channel: "whatsapp", messageId: "w2", toJid: "jid" },
|
||||
]);
|
||||
expect(results).toEqual([{ channel: "whatsapp", messageId: "w2", toJid: "jid" }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,10 +49,7 @@ type ChannelHandler = {
|
||||
chunker: Chunker | null;
|
||||
textChunkLimit?: number;
|
||||
sendText: (text: string) => Promise<OutboundDeliveryResult>;
|
||||
sendMedia: (
|
||||
caption: string,
|
||||
mediaUrl: string,
|
||||
) => Promise<OutboundDeliveryResult>;
|
||||
sendMedia: (caption: string, mediaUrl: string) => Promise<OutboundDeliveryResult>;
|
||||
};
|
||||
|
||||
function throwIfAborted(abortSignal?: AbortSignal): void {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import type { OutboundDeliveryJson } from "./format.js";
|
||||
import {
|
||||
normalizeOutboundPayloadsForJson,
|
||||
type OutboundPayloadJson,
|
||||
} from "./payloads.js";
|
||||
import { normalizeOutboundPayloadsForJson, type OutboundPayloadJson } from "./payloads.js";
|
||||
|
||||
export type OutboundResultEnvelope = {
|
||||
payloads?: OutboundPayloadJson[];
|
||||
@@ -35,12 +32,7 @@ export function buildOutboundResultEnvelope(
|
||||
? (params.payloads as OutboundPayloadJson[])
|
||||
: normalizeOutboundPayloadsForJson(params.payloads as ReplyPayload[]);
|
||||
|
||||
if (
|
||||
params.flattenDelivery !== false &&
|
||||
params.delivery &&
|
||||
!params.meta &&
|
||||
!hasPayloads
|
||||
) {
|
||||
if (params.flattenDelivery !== false && params.delivery && !params.meta && !hasPayloads) {
|
||||
return params.delivery;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,8 +42,7 @@ export function formatOutboundDeliverySummary(
|
||||
|
||||
if ("chatId" in result) return `${base} (chat ${result.chatId})`;
|
||||
if ("channelId" in result) return `${base} (channel ${result.channelId})`;
|
||||
if ("conversationId" in result)
|
||||
return `${base} (conversation ${result.conversationId})`;
|
||||
if ("conversationId" in result) return `${base} (conversation ${result.conversationId})`;
|
||||
return base;
|
||||
}
|
||||
|
||||
@@ -70,11 +69,7 @@ export function buildOutboundDeliveryJson(params: {
|
||||
if (result && "channelId" in result && result.channelId !== undefined) {
|
||||
payload.channelId = result.channelId;
|
||||
}
|
||||
if (
|
||||
result &&
|
||||
"conversationId" in result &&
|
||||
result.conversationId !== undefined
|
||||
) {
|
||||
if (result && "conversationId" in result && result.conversationId !== undefined) {
|
||||
payload.conversationId = result.conversationId;
|
||||
}
|
||||
if (result && "timestamp" in result && result.timestamp !== undefined) {
|
||||
|
||||
@@ -13,10 +13,7 @@ import type {
|
||||
ChannelThreadingToolContext,
|
||||
} from "../../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type {
|
||||
GatewayClientMode,
|
||||
GatewayClientName,
|
||||
} from "../../utils/message-channel.js";
|
||||
import type { GatewayClientMode, GatewayClientName } from "../../utils/message-channel.js";
|
||||
import { resolveMessageChannelSelection } from "./channel-selection.js";
|
||||
import type { OutboundSendDeps } from "./deliver.js";
|
||||
import type { MessagePollResult, MessageSendResult } from "./message.js";
|
||||
@@ -97,10 +94,7 @@ function extractToolPayload(result: AgentToolResult<unknown>): unknown {
|
||||
return result.content ?? result;
|
||||
}
|
||||
|
||||
function readBooleanParam(
|
||||
params: Record<string, unknown>,
|
||||
key: string,
|
||||
): boolean | undefined {
|
||||
function readBooleanParam(params: Record<string, unknown>, key: string): boolean | undefined {
|
||||
const raw = params[key];
|
||||
if (typeof raw === "boolean") return raw;
|
||||
if (typeof raw === "string") {
|
||||
@@ -141,9 +135,7 @@ function resolveContextGuardTarget(
|
||||
if (!CONTEXT_GUARDED_ACTIONS.has(action)) return undefined;
|
||||
|
||||
if (action === "thread-reply" || action === "thread-create") {
|
||||
return (
|
||||
readStringParam(params, "channelId") ?? readStringParam(params, "to")
|
||||
);
|
||||
return readStringParam(params, "channelId") ?? readStringParam(params, "to");
|
||||
}
|
||||
|
||||
return readStringParam(params, "to") ?? readStringParam(params, "channelId");
|
||||
@@ -165,8 +157,7 @@ function enforceContextIsolation(params: {
|
||||
const normalizedTarget =
|
||||
normalizeTargetForProvider(params.channel, target) ?? target.toLowerCase();
|
||||
const normalizedCurrent =
|
||||
normalizeTargetForProvider(params.channel, currentTarget) ??
|
||||
currentTarget.toLowerCase();
|
||||
normalizeTargetForProvider(params.channel, currentTarget) ?? currentTarget.toLowerCase();
|
||||
|
||||
if (!normalizedTarget || !normalizedCurrent) return;
|
||||
if (normalizedTarget === normalizedCurrent) return;
|
||||
@@ -176,10 +167,7 @@ function enforceContextIsolation(params: {
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveChannel(
|
||||
cfg: ClawdbotConfig,
|
||||
params: Record<string, unknown>,
|
||||
) {
|
||||
async function resolveChannel(cfg: ClawdbotConfig, params: Record<string, unknown>) {
|
||||
const channelHint = readStringParam(params, "channel");
|
||||
const selection = await resolveMessageChannelSelection({
|
||||
cfg,
|
||||
@@ -197,8 +185,7 @@ export async function runMessageAction(
|
||||
|
||||
const action = input.action;
|
||||
const channel = await resolveChannel(cfg, params);
|
||||
const accountId =
|
||||
readStringParam(params, "accountId") ?? input.defaultAccountId;
|
||||
const accountId = readStringParam(params, "accountId") ?? input.defaultAccountId;
|
||||
const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun"));
|
||||
|
||||
enforceContextIsolation({
|
||||
@@ -294,8 +281,7 @@ export async function runMessageAction(
|
||||
const question = readStringParam(params, "pollQuestion", {
|
||||
required: true,
|
||||
});
|
||||
const options =
|
||||
readStringArrayParam(params, "pollOption", { required: true }) ?? [];
|
||||
const options = readStringArrayParam(params, "pollOption", { required: true }) ?? [];
|
||||
if (options.length < 2) {
|
||||
throw new Error("pollOption requires at least two values");
|
||||
}
|
||||
@@ -376,9 +362,7 @@ export async function runMessageAction(
|
||||
dryRun,
|
||||
});
|
||||
if (!handled) {
|
||||
throw new Error(
|
||||
`Message action ${action} not supported for channel ${channel}.`,
|
||||
);
|
||||
throw new Error(`Message action ${action} not supported for channel ${channel}.`);
|
||||
}
|
||||
return {
|
||||
kind: "action",
|
||||
|
||||
@@ -26,10 +26,7 @@ describe("sendMessage channel normalization", () => {
|
||||
deps: { sendMSTeams },
|
||||
});
|
||||
|
||||
expect(sendMSTeams).toHaveBeenCalledWith(
|
||||
"conversation:19:abc@thread.tacv2",
|
||||
"hi",
|
||||
);
|
||||
expect(sendMSTeams).toHaveBeenCalledWith("conversation:19:abc@thread.tacv2", "hi");
|
||||
expect(result.channel).toBe("msteams");
|
||||
});
|
||||
|
||||
@@ -43,11 +40,7 @@ describe("sendMessage channel normalization", () => {
|
||||
deps: { sendIMessage },
|
||||
});
|
||||
|
||||
expect(sendIMessage).toHaveBeenCalledWith(
|
||||
"someone@example.com",
|
||||
"hi",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(sendIMessage).toHaveBeenCalledWith("someone@example.com", "hi", expect.any(Object));
|
||||
expect(result.channel).toBe("imessage");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
getChannelPlugin,
|
||||
normalizeChannelId,
|
||||
} from "../../channels/plugins/index.js";
|
||||
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
@@ -101,9 +98,7 @@ function resolveGatewayOptions(opts?: MessageGatewayOptions) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
params: MessageSendParams,
|
||||
): Promise<MessageSendResult> {
|
||||
export async function sendMessage(params: MessageSendParams): Promise<MessageSendResult> {
|
||||
const cfg = params.cfg ?? loadConfig();
|
||||
const channel = params.channel?.trim()
|
||||
? normalizeChannelId(params.channel)
|
||||
@@ -187,9 +182,7 @@ export async function sendMessage(
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendPoll(
|
||||
params: MessagePollParams,
|
||||
): Promise<MessagePollResult> {
|
||||
export async function sendPoll(params: MessagePollParams): Promise<MessagePollResult> {
|
||||
const cfg = params.cfg ?? loadConfig();
|
||||
const channel = params.channel?.trim()
|
||||
? normalizeChannelId(params.channel)
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
formatOutboundPayloadLog,
|
||||
normalizeOutboundPayloadsForJson,
|
||||
} from "./payloads.js";
|
||||
import { formatOutboundPayloadLog, normalizeOutboundPayloadsForJson } from "./payloads.js";
|
||||
|
||||
describe("normalizeOutboundPayloadsForJson", () => {
|
||||
it("normalizes payloads with mediaUrl and mediaUrls", () => {
|
||||
|
||||
@@ -11,32 +11,24 @@ export type OutboundPayloadJson = {
|
||||
mediaUrls?: string[];
|
||||
};
|
||||
|
||||
export function normalizeOutboundPayloads(
|
||||
payloads: ReplyPayload[],
|
||||
): NormalizedOutboundPayload[] {
|
||||
export function normalizeOutboundPayloads(payloads: ReplyPayload[]): NormalizedOutboundPayload[] {
|
||||
return payloads
|
||||
.map((payload) => ({
|
||||
text: payload.text ?? "",
|
||||
mediaUrls:
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
|
||||
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
|
||||
}))
|
||||
.filter((payload) => payload.text || payload.mediaUrls.length > 0);
|
||||
}
|
||||
|
||||
export function normalizeOutboundPayloadsForJson(
|
||||
payloads: ReplyPayload[],
|
||||
): OutboundPayloadJson[] {
|
||||
export function normalizeOutboundPayloadsForJson(payloads: ReplyPayload[]): OutboundPayloadJson[] {
|
||||
return payloads.map((payload) => ({
|
||||
text: payload.text ?? "",
|
||||
mediaUrl: payload.mediaUrl ?? null,
|
||||
mediaUrls:
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined),
|
||||
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined),
|
||||
}));
|
||||
}
|
||||
|
||||
export function formatOutboundPayloadLog(
|
||||
payload: NormalizedOutboundPayload,
|
||||
): string {
|
||||
export function formatOutboundPayloadLog(payload: NormalizedOutboundPayload): string {
|
||||
const lines: string[] = [];
|
||||
if (payload.text) lines.push(payload.text.trimEnd());
|
||||
for (const url of payload.mediaUrls) lines.push(`MEDIA:${url}`);
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import {
|
||||
getChannelPlugin,
|
||||
normalizeChannelId,
|
||||
} from "../../channels/plugins/index.js";
|
||||
import type {
|
||||
ChannelId,
|
||||
ChannelOutboundTargetMode,
|
||||
} from "../../channels/plugins/types.js";
|
||||
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import type { ChannelId, ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import type {
|
||||
@@ -24,9 +18,7 @@ export type OutboundTarget = {
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type OutboundTargetResolution =
|
||||
| { ok: true; to: string }
|
||||
| { ok: false; error: Error };
|
||||
export type OutboundTargetResolution = { ok: true; to: string } | { ok: false; error: Error };
|
||||
|
||||
// Channel docking: prefer plugin.outbound.resolveTarget + allowFrom to normalize destinations.
|
||||
export function resolveOutboundTarget(params: {
|
||||
@@ -151,7 +143,5 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
||||
}
|
||||
}
|
||||
|
||||
return reason
|
||||
? { channel, to: resolved.to, reason }
|
||||
: { channel, to: resolved.to };
|
||||
return reason ? { channel, to: resolved.to, reason } : { channel, to: resolved.to };
|
||||
}
|
||||
|
||||
@@ -31,8 +31,7 @@ describe("ensureClawdbotCliOnPath", () => {
|
||||
expect(updated.split(path.delimiter)[0]).toBe(relayDir);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
if (originalFlag === undefined)
|
||||
delete process.env.CLAWDBOT_PATH_BOOTSTRAPPED;
|
||||
if (originalFlag === undefined) delete process.env.CLAWDBOT_PATH_BOOTSTRAPPED;
|
||||
else process.env.CLAWDBOT_PATH_BOOTSTRAPPED = originalFlag;
|
||||
}
|
||||
} finally {
|
||||
@@ -55,8 +54,7 @@ describe("ensureClawdbotCliOnPath", () => {
|
||||
expect(process.env.PATH).toBe("/bin");
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
if (originalFlag === undefined)
|
||||
delete process.env.CLAWDBOT_PATH_BOOTSTRAPPED;
|
||||
if (originalFlag === undefined) delete process.env.CLAWDBOT_PATH_BOOTSTRAPPED;
|
||||
else process.env.CLAWDBOT_PATH_BOOTSTRAPPED = originalFlag;
|
||||
}
|
||||
});
|
||||
@@ -103,8 +101,7 @@ describe("ensureClawdbotCliOnPath", () => {
|
||||
expect(shimsIndex).toBeGreaterThan(localIndex);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
if (originalFlag === undefined)
|
||||
delete process.env.CLAWDBOT_PATH_BOOTSTRAPPED;
|
||||
if (originalFlag === undefined) delete process.env.CLAWDBOT_PATH_BOOTSTRAPPED;
|
||||
else process.env.CLAWDBOT_PATH_BOOTSTRAPPED = originalFlag;
|
||||
if (originalMiseDataDir === undefined) delete process.env.MISE_DATA_DIR;
|
||||
else process.env.MISE_DATA_DIR = originalMiseDataDir;
|
||||
@@ -147,14 +144,11 @@ describe("ensureClawdbotCliOnPath", () => {
|
||||
expect(parts[1]).toBe(linuxbrewSbin);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
if (originalFlag === undefined)
|
||||
delete process.env.CLAWDBOT_PATH_BOOTSTRAPPED;
|
||||
if (originalFlag === undefined) delete process.env.CLAWDBOT_PATH_BOOTSTRAPPED;
|
||||
else process.env.CLAWDBOT_PATH_BOOTSTRAPPED = originalFlag;
|
||||
if (originalHomebrewPrefix === undefined)
|
||||
delete process.env.HOMEBREW_PREFIX;
|
||||
if (originalHomebrewPrefix === undefined) delete process.env.HOMEBREW_PREFIX;
|
||||
else process.env.HOMEBREW_PREFIX = originalHomebrewPrefix;
|
||||
if (originalHomebrewBrewFile === undefined)
|
||||
delete process.env.HOMEBREW_BREW_FILE;
|
||||
if (originalHomebrewBrewFile === undefined) delete process.env.HOMEBREW_BREW_FILE;
|
||||
else process.env.HOMEBREW_BREW_FILE = originalHomebrewBrewFile;
|
||||
if (originalXdgBinHome === undefined) delete process.env.XDG_BIN_HOME;
|
||||
else process.env.XDG_BIN_HOME = originalXdgBinHome;
|
||||
|
||||
@@ -34,9 +34,7 @@ function mergePath(params: { existing: string; prepend: string[] }): string {
|
||||
.split(path.delimiter)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
const partsPrepend = params.prepend
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
const partsPrepend = params.prepend.map((part) => part.trim()).filter(Boolean);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const merged: string[] = [];
|
||||
@@ -69,11 +67,9 @@ function candidateBinDirs(opts: EnsureClawdbotPathOpts): string[] {
|
||||
// Project-local installs (best effort): if a `node_modules/.bin/clawdbot` exists near cwd,
|
||||
// include it. This helps when running under launchd or other minimal PATH environments.
|
||||
const localBinDir = path.join(cwd, "node_modules", ".bin");
|
||||
if (isExecutable(path.join(localBinDir, "clawdbot")))
|
||||
candidates.push(localBinDir);
|
||||
if (isExecutable(path.join(localBinDir, "clawdbot"))) candidates.push(localBinDir);
|
||||
|
||||
const miseDataDir =
|
||||
process.env.MISE_DATA_DIR ?? path.join(homeDir, ".local", "share", "mise");
|
||||
const miseDataDir = process.env.MISE_DATA_DIR ?? path.join(homeDir, ".local", "share", "mise");
|
||||
const miseShims = path.join(miseDataDir, "shims");
|
||||
if (isDirectory(miseShims)) candidates.push(miseShims);
|
||||
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import type {
|
||||
PortListener,
|
||||
PortListenerKind,
|
||||
PortUsage,
|
||||
} from "./ports-types.js";
|
||||
import type { PortListener, PortListenerKind, PortUsage } from "./ports-types.js";
|
||||
|
||||
export function classifyPortListener(
|
||||
listener: PortListener,
|
||||
port: number,
|
||||
): PortListenerKind {
|
||||
const raw = `${listener.commandLine ?? ""} ${listener.command ?? ""}`
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
export function classifyPortListener(listener: PortListener, port: number): PortListenerKind {
|
||||
const raw = `${listener.commandLine ?? ""} ${listener.command ?? ""}`.trim().toLowerCase();
|
||||
if (raw.includes("clawdbot") || raw.includes("clawdis")) return "gateway";
|
||||
if (raw.includes("ssh")) {
|
||||
const portToken = String(port);
|
||||
@@ -23,14 +14,9 @@ export function classifyPortListener(
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function buildPortHints(
|
||||
listeners: PortListener[],
|
||||
port: number,
|
||||
): string[] {
|
||||
export function buildPortHints(listeners: PortListener[], port: number): string[] {
|
||||
if (listeners.length === 0) return [];
|
||||
const kinds = new Set(
|
||||
listeners.map((listener) => classifyPortListener(listener, port)),
|
||||
);
|
||||
const kinds = new Set(listeners.map((listener) => classifyPortListener(listener, port)));
|
||||
const hints: string[] = [];
|
||||
if (kinds.has("gateway")) {
|
||||
hints.push(
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import net from "node:net";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { buildPortHints } from "./ports-format.js";
|
||||
import type {
|
||||
PortListener,
|
||||
PortUsage,
|
||||
PortUsageStatus,
|
||||
} from "./ports-types.js";
|
||||
import type { PortListener, PortUsage, PortUsageStatus } from "./ports-types.js";
|
||||
|
||||
type CommandResult = {
|
||||
stdout: string;
|
||||
@@ -18,10 +14,7 @@ function isErrno(err: unknown): err is NodeJS.ErrnoException {
|
||||
return Boolean(err && typeof err === "object" && "code" in err);
|
||||
}
|
||||
|
||||
async function runCommandSafe(
|
||||
argv: string[],
|
||||
timeoutMs = 5_000,
|
||||
): Promise<CommandResult> {
|
||||
async function runCommandSafe(argv: string[], timeoutMs = 5_000): Promise<CommandResult> {
|
||||
try {
|
||||
const res = await runCommandWithTimeout(argv, { timeoutMs });
|
||||
return {
|
||||
@@ -60,9 +53,7 @@ function parseLsofFieldOutput(output: string): PortListener[] {
|
||||
return listeners;
|
||||
}
|
||||
|
||||
async function resolveUnixCommandLine(
|
||||
pid: number,
|
||||
): Promise<string | undefined> {
|
||||
async function resolveUnixCommandLine(pid: number): Promise<string | undefined> {
|
||||
const res = await runCommandSafe(["ps", "-p", String(pid), "-o", "command="]);
|
||||
if (res.code !== 0) return undefined;
|
||||
const line = res.stdout.trim();
|
||||
@@ -80,13 +71,7 @@ async function readUnixListeners(
|
||||
port: number,
|
||||
): Promise<{ listeners: PortListener[]; detail?: string; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
const res = await runCommandSafe([
|
||||
"lsof",
|
||||
"-nP",
|
||||
`-iTCP:${port}`,
|
||||
"-sTCP:LISTEN",
|
||||
"-FpFcn",
|
||||
]);
|
||||
const res = await runCommandSafe(["lsof", "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-FpFcn"]);
|
||||
if (res.code === 0) {
|
||||
const listeners = parseLsofFieldOutput(res.stdout);
|
||||
await Promise.all(
|
||||
@@ -106,9 +91,7 @@ async function readUnixListeners(
|
||||
return { listeners: [], detail: undefined, errors };
|
||||
}
|
||||
if (res.error) errors.push(res.error);
|
||||
const detail = [res.stderr.trim(), res.stdout.trim()]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const detail = [res.stderr.trim(), res.stdout.trim()].filter(Boolean).join("\n");
|
||||
if (detail) errors.push(detail);
|
||||
return { listeners: [], detail: undefined, errors };
|
||||
}
|
||||
@@ -134,16 +117,8 @@ function parseNetstatListeners(output: string, port: number): PortListener[] {
|
||||
return listeners;
|
||||
}
|
||||
|
||||
async function resolveWindowsImageName(
|
||||
pid: number,
|
||||
): Promise<string | undefined> {
|
||||
const res = await runCommandSafe([
|
||||
"tasklist",
|
||||
"/FI",
|
||||
`PID eq ${pid}`,
|
||||
"/FO",
|
||||
"LIST",
|
||||
]);
|
||||
async function resolveWindowsImageName(pid: number): Promise<string | undefined> {
|
||||
const res = await runCommandSafe(["tasklist", "/FI", `PID eq ${pid}`, "/FO", "LIST"]);
|
||||
if (res.code !== 0) return undefined;
|
||||
for (const rawLine of res.stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
@@ -154,9 +129,7 @@ async function resolveWindowsImageName(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function resolveWindowsCommandLine(
|
||||
pid: number,
|
||||
): Promise<string | undefined> {
|
||||
async function resolveWindowsCommandLine(pid: number): Promise<string | undefined> {
|
||||
const res = await runCommandSafe([
|
||||
"wmic",
|
||||
"process",
|
||||
@@ -183,9 +156,7 @@ async function readWindowsListeners(
|
||||
const res = await runCommandSafe(["netstat", "-ano", "-p", "tcp"]);
|
||||
if (res.code !== 0) {
|
||||
if (res.error) errors.push(res.error);
|
||||
const detail = [res.stderr.trim(), res.stdout.trim()]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const detail = [res.stderr.trim(), res.stdout.trim()].filter(Boolean).join("\n");
|
||||
if (detail) errors.push(detail);
|
||||
return { listeners: [], errors };
|
||||
}
|
||||
@@ -225,9 +196,7 @@ async function checkPortInUse(port: number): Promise<PortUsageStatus> {
|
||||
export async function inspectPortUsage(port: number): Promise<PortUsage> {
|
||||
const errors: string[] = [];
|
||||
const result =
|
||||
process.platform === "win32"
|
||||
? await readWindowsListeners(port)
|
||||
: await readUnixListeners(port);
|
||||
process.platform === "win32" ? await readWindowsListeners(port) : await readUnixListeners(port);
|
||||
errors.push(...result.errors);
|
||||
let listeners = result.listeners;
|
||||
let status: PortUsageStatus = listeners.length > 0 ? "busy" : "unknown";
|
||||
|
||||
@@ -15,9 +15,7 @@ describe("ports helpers", () => {
|
||||
const server = net.createServer();
|
||||
await new Promise((resolve) => server.listen(0, resolve));
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
await expect(ensurePortAvailable(port)).rejects.toBeInstanceOf(
|
||||
PortInUseError,
|
||||
);
|
||||
await expect(ensurePortAvailable(port)).rejects.toBeInstanceOf(PortInUseError);
|
||||
server.close();
|
||||
});
|
||||
|
||||
@@ -27,22 +25,14 @@ describe("ports helpers", () => {
|
||||
log: vi.fn(),
|
||||
exit: vi.fn() as unknown as (code: number) => never,
|
||||
};
|
||||
await handlePortError(
|
||||
{ code: "EADDRINUSE" },
|
||||
1234,
|
||||
"context",
|
||||
runtime,
|
||||
).catch(() => {});
|
||||
await handlePortError({ code: "EADDRINUSE" }, 1234, "context", runtime).catch(() => {});
|
||||
expect(runtime.error).toHaveBeenCalled();
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("classifies ssh and gateway listeners", () => {
|
||||
expect(
|
||||
classifyPortListener(
|
||||
{ commandLine: "ssh -N -L 18789:127.0.0.1:18789 user@host" },
|
||||
18789,
|
||||
),
|
||||
classifyPortListener({ commandLine: "ssh -N -L 18789:127.0.0.1:18789 user@host" }, 18789),
|
||||
).toBe("ssh");
|
||||
expect(
|
||||
classifyPortListener(
|
||||
@@ -59,10 +49,7 @@ describe("ports helpers", () => {
|
||||
port: 18789,
|
||||
status: "busy" as const,
|
||||
listeners: [{ pid: 123, commandLine: "ssh -N -L 18789:127.0.0.1:18789" }],
|
||||
hints: buildPortHints(
|
||||
[{ pid: 123, commandLine: "ssh -N -L 18789:127.0.0.1:18789" }],
|
||||
18789,
|
||||
),
|
||||
hints: buildPortHints([{ pid: 123, commandLine: "ssh -N -L 18789:127.0.0.1:18789" }], 18789),
|
||||
};
|
||||
const lines = formatPortDiagnostics(diagnostics);
|
||||
expect(lines[0]).toContain("Port 18789 is already in use");
|
||||
|
||||
@@ -5,12 +5,7 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatPortDiagnostics } from "./ports-format.js";
|
||||
import { inspectPortUsage } from "./ports-inspect.js";
|
||||
import type {
|
||||
PortListener,
|
||||
PortListenerKind,
|
||||
PortUsage,
|
||||
PortUsageStatus,
|
||||
} from "./ports-types.js";
|
||||
import type { PortListener, PortListenerKind, PortUsage, PortUsageStatus } from "./ports-types.js";
|
||||
|
||||
class PortInUseError extends Error {
|
||||
port: number;
|
||||
@@ -28,9 +23,7 @@ function isErrno(err: unknown): err is NodeJS.ErrnoException {
|
||||
return Boolean(err && typeof err === "object" && "code" in err);
|
||||
}
|
||||
|
||||
export async function describePortOwner(
|
||||
port: number,
|
||||
): Promise<string | undefined> {
|
||||
export async function describePortOwner(port: number): Promise<string | undefined> {
|
||||
const diagnostics = await inspectPortUsage(port);
|
||||
if (diagnostics.listeners.length === 0) return undefined;
|
||||
return formatPortDiagnostics(diagnostics).join("\n");
|
||||
@@ -64,14 +57,8 @@ export async function handlePortError(
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): Promise<never> {
|
||||
// Uniform messaging for EADDRINUSE with optional owner details.
|
||||
if (
|
||||
err instanceof PortInUseError ||
|
||||
(isErrno(err) && err.code === "EADDRINUSE")
|
||||
) {
|
||||
const details =
|
||||
err instanceof PortInUseError
|
||||
? err.details
|
||||
: await describePortOwner(port);
|
||||
if (err instanceof PortInUseError || (isErrno(err) && err.code === "EADDRINUSE")) {
|
||||
const details = err instanceof PortInUseError ? err.details : await describePortOwner(port);
|
||||
runtime.error(danger(`${context} failed: port ${port} is already in use.`));
|
||||
if (details) {
|
||||
runtime.error(info("Port listener details:"));
|
||||
@@ -85,9 +72,7 @@ export async function handlePortError(
|
||||
}
|
||||
}
|
||||
runtime.error(
|
||||
info(
|
||||
"Resolve by stopping the process using the port or passing --port <free-port>.",
|
||||
),
|
||||
info("Resolve by stopping the process using the port or passing --port <free-port>."),
|
||||
);
|
||||
runtime.exit(1);
|
||||
}
|
||||
@@ -103,9 +88,5 @@ export async function handlePortError(
|
||||
|
||||
export { PortInUseError };
|
||||
export type { PortListener, PortListenerKind, PortUsage, PortUsageStatus };
|
||||
export {
|
||||
buildPortHints,
|
||||
classifyPortListener,
|
||||
formatPortDiagnostics,
|
||||
} from "./ports-format.js";
|
||||
export { buildPortHints, classifyPortListener, formatPortDiagnostics } from "./ports-format.js";
|
||||
export { inspectPortUsage } from "./ports-inspect.js";
|
||||
|
||||
@@ -9,10 +9,7 @@ import {
|
||||
resolveApiKeyForProfile,
|
||||
resolveAuthProfileOrder,
|
||||
} from "../agents/auth-profiles.js";
|
||||
import {
|
||||
getCustomProviderApiKey,
|
||||
resolveEnvApiKey,
|
||||
} from "../agents/model-auth.js";
|
||||
import { getCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js";
|
||||
import { normalizeProviderId } from "../agents/model-selection.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import type { UsageProviderId } from "./provider-usage.types.js";
|
||||
@@ -37,16 +34,14 @@ function parseGoogleToken(apiKey: string): { token: string } | null {
|
||||
}
|
||||
|
||||
function resolveZaiApiKey(): string | undefined {
|
||||
const envDirect =
|
||||
process.env.ZAI_API_KEY?.trim() || process.env.Z_AI_API_KEY?.trim();
|
||||
const envDirect = process.env.ZAI_API_KEY?.trim() || process.env.Z_AI_API_KEY?.trim();
|
||||
if (envDirect) return envDirect;
|
||||
|
||||
const envResolved = resolveEnvApiKey("zai");
|
||||
if (envResolved?.apiKey) return envResolved.apiKey;
|
||||
|
||||
const cfg = loadConfig();
|
||||
const key =
|
||||
getCustomProviderApiKey(cfg, "zai") || getCustomProviderApiKey(cfg, "z-ai");
|
||||
const key = getCustomProviderApiKey(cfg, "zai") || getCustomProviderApiKey(cfg, "z-ai");
|
||||
if (key) return key;
|
||||
|
||||
const store = ensureAuthProfileStore();
|
||||
@@ -76,8 +71,7 @@ function resolveZaiApiKey(): string | undefined {
|
||||
|
||||
function resolveMinimaxApiKey(): string | undefined {
|
||||
const envDirect =
|
||||
process.env.MINIMAX_CODE_PLAN_KEY?.trim() ||
|
||||
process.env.MINIMAX_API_KEY?.trim();
|
||||
process.env.MINIMAX_CODE_PLAN_KEY?.trim() || process.env.MINIMAX_API_KEY?.trim();
|
||||
if (envDirect) return envDirect;
|
||||
|
||||
const envResolved = resolveEnvApiKey("minimax");
|
||||
@@ -119,8 +113,7 @@ async function resolveOAuthToken(params: {
|
||||
|
||||
// Claude CLI creds are the only Anthropic tokens that reliably include the
|
||||
// `user:profile` scope required for the OAuth usage endpoint.
|
||||
const candidates =
|
||||
params.provider === "anthropic" ? [CLAUDE_CLI_PROFILE_ID, ...order] : order;
|
||||
const candidates = params.provider === "anthropic" ? [CLAUDE_CLI_PROFILE_ID, ...order] : order;
|
||||
const deduped: string[] = [];
|
||||
for (const entry of candidates) {
|
||||
if (!deduped.includes(entry)) deduped.push(entry);
|
||||
@@ -140,10 +133,7 @@ async function resolveOAuthToken(params: {
|
||||
});
|
||||
if (!resolved?.apiKey) continue;
|
||||
let token = resolved.apiKey;
|
||||
if (
|
||||
params.provider === "google-gemini-cli" ||
|
||||
params.provider === "google-antigravity"
|
||||
) {
|
||||
if (params.provider === "google-gemini-cli" || params.provider === "google-antigravity") {
|
||||
const parsed = parseGoogleToken(resolved.apiKey);
|
||||
token = parsed?.token ?? resolved.apiKey;
|
||||
}
|
||||
@@ -180,15 +170,11 @@ function resolveOAuthProviders(agentDir?: string): UsageProviderId[] {
|
||||
return cred?.type === "oauth" || cred?.type === "token";
|
||||
};
|
||||
return providers.filter((provider) => {
|
||||
const profiles = listProfilesForProvider(store, provider).filter(
|
||||
isOAuthLikeCredential,
|
||||
);
|
||||
const profiles = listProfilesForProvider(store, provider).filter(isOAuthLikeCredential);
|
||||
if (profiles.length > 0) return true;
|
||||
const normalized = normalizeProviderId(provider);
|
||||
const configuredProfiles = Object.entries(cfg.auth?.profiles ?? {})
|
||||
.filter(
|
||||
([, profile]) => normalizeProviderId(profile.provider) === normalized,
|
||||
)
|
||||
.filter(([, profile]) => normalizeProviderId(profile.provider) === normalized)
|
||||
.map(([id]) => id)
|
||||
.filter(isOAuthLikeCredential);
|
||||
return configuredProfiles.length > 0;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { fetchJson } from "./provider-usage.fetch.shared.js";
|
||||
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
|
||||
import type {
|
||||
ProviderUsageSnapshot,
|
||||
UsageWindow,
|
||||
} from "./provider-usage.types.js";
|
||||
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
|
||||
|
||||
type ClaudeUsageResponse = {
|
||||
five_hour?: { utilization?: number; resets_at?: string };
|
||||
@@ -21,8 +18,7 @@ type ClaudeWebUsageResponse = ClaudeUsageResponse;
|
||||
|
||||
function resolveClaudeWebSessionKey(): string | undefined {
|
||||
const direct =
|
||||
process.env.CLAUDE_AI_SESSION_KEY?.trim() ??
|
||||
process.env.CLAUDE_WEB_SESSION_KEY?.trim();
|
||||
process.env.CLAUDE_AI_SESSION_KEY?.trim() ?? process.env.CLAUDE_WEB_SESSION_KEY?.trim();
|
||||
if (direct?.startsWith("sk-ant-")) return direct;
|
||||
|
||||
const cookieHeader = process.env.CLAUDE_WEB_COOKIE?.trim();
|
||||
@@ -70,9 +66,7 @@ async function fetchClaudeWebUsage(
|
||||
windows.push({
|
||||
label: "5h",
|
||||
usedPercent: clampPercent(data.five_hour.utilization),
|
||||
resetAt: data.five_hour.resets_at
|
||||
? new Date(data.five_hour.resets_at).getTime()
|
||||
: undefined,
|
||||
resetAt: data.five_hour.resets_at ? new Date(data.five_hour.resets_at).getTime() : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -80,9 +74,7 @@ async function fetchClaudeWebUsage(
|
||||
windows.push({
|
||||
label: "Week",
|
||||
usedPercent: clampPercent(data.seven_day.utilization),
|
||||
resetAt: data.seven_day.resets_at
|
||||
? new Date(data.seven_day.resets_at).getTime()
|
||||
: undefined,
|
||||
resetAt: data.seven_day.resets_at ? new Date(data.seven_day.resets_at).getTime() : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -137,10 +129,7 @@ export async function fetchClaudeUsage(
|
||||
// Claude CLI setup-token yields tokens that can be used for inference, but may not
|
||||
// include user:profile scope required by the OAuth usage endpoint. When a claude.ai
|
||||
// browser sessionKey is available, fall back to the web API.
|
||||
if (
|
||||
res.status === 403 &&
|
||||
message?.includes("scope requirement user:profile")
|
||||
) {
|
||||
if (res.status === 403 && message?.includes("scope requirement user:profile")) {
|
||||
const sessionKey = resolveClaudeWebSessionKey();
|
||||
if (sessionKey) {
|
||||
const web = await fetchClaudeWebUsage(sessionKey, timeoutMs, fetchFn);
|
||||
@@ -164,9 +153,7 @@ export async function fetchClaudeUsage(
|
||||
windows.push({
|
||||
label: "5h",
|
||||
usedPercent: clampPercent(data.five_hour.utilization),
|
||||
resetAt: data.five_hour.resets_at
|
||||
? new Date(data.five_hour.resets_at).getTime()
|
||||
: undefined,
|
||||
resetAt: data.five_hour.resets_at ? new Date(data.five_hour.resets_at).getTime() : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -174,9 +161,7 @@ export async function fetchClaudeUsage(
|
||||
windows.push({
|
||||
label: "Week",
|
||||
usedPercent: clampPercent(data.seven_day.utilization),
|
||||
resetAt: data.seven_day.resets_at
|
||||
? new Date(data.seven_day.resets_at).getTime()
|
||||
: undefined,
|
||||
resetAt: data.seven_day.resets_at ? new Date(data.seven_day.resets_at).getTime() : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { fetchJson } from "./provider-usage.fetch.shared.js";
|
||||
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
|
||||
import type {
|
||||
ProviderUsageSnapshot,
|
||||
UsageWindow,
|
||||
} from "./provider-usage.types.js";
|
||||
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
|
||||
|
||||
type CodexUsageResponse = {
|
||||
rate_limit?: {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { fetchJson } from "./provider-usage.fetch.shared.js";
|
||||
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
|
||||
import type {
|
||||
ProviderUsageSnapshot,
|
||||
UsageWindow,
|
||||
} from "./provider-usage.types.js";
|
||||
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
|
||||
|
||||
type CopilotUsageResponse = {
|
||||
quota_snapshots?: {
|
||||
@@ -45,8 +42,7 @@ export async function fetchCopilotUsage(
|
||||
const windows: UsageWindow[] = [];
|
||||
|
||||
if (data.quota_snapshots?.premium_interactions) {
|
||||
const remaining =
|
||||
data.quota_snapshots.premium_interactions.percent_remaining;
|
||||
const remaining = data.quota_snapshots.premium_interactions.percent_remaining;
|
||||
windows.push({
|
||||
label: "Premium",
|
||||
usedPercent: clampPercent(100 - (remaining ?? 0)),
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { fetchJson } from "./provider-usage.fetch.shared.js";
|
||||
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
|
||||
import type {
|
||||
ProviderUsageSnapshot,
|
||||
UsageWindow,
|
||||
} from "./provider-usage.types.js";
|
||||
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
|
||||
|
||||
type MinimaxBaseResp = {
|
||||
status_code?: number;
|
||||
@@ -115,10 +112,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function pickNumber(
|
||||
record: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
): number | undefined {
|
||||
function pickNumber(record: Record<string, unknown>, keys: readonly string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
@@ -130,10 +124,7 @@ function pickNumber(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pickString(
|
||||
record: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
): string | undefined {
|
||||
function pickString(record: Record<string, unknown>, keys: readonly string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
@@ -217,14 +208,8 @@ export async function fetchMinimaxUsage(
|
||||
};
|
||||
}
|
||||
|
||||
const baseResp = isRecord(data.base_resp)
|
||||
? (data.base_resp as MinimaxBaseResp)
|
||||
: undefined;
|
||||
if (
|
||||
baseResp &&
|
||||
typeof baseResp.status_code === "number" &&
|
||||
baseResp.status_code !== 0
|
||||
) {
|
||||
const baseResp = isRecord(data.base_resp) ? (data.base_resp as MinimaxBaseResp) : undefined;
|
||||
if (baseResp && typeof baseResp.status_code === "number" && baseResp.status_code !== 0) {
|
||||
return {
|
||||
provider: "minimax",
|
||||
displayName: PROVIDER_LABELS.minimax,
|
||||
@@ -245,8 +230,7 @@ export async function fetchMinimaxUsage(
|
||||
}
|
||||
|
||||
const resetAt =
|
||||
parseEpoch(pickString(payload, RESET_KEYS)) ??
|
||||
parseEpoch(pickNumber(payload, RESET_KEYS));
|
||||
parseEpoch(pickString(payload, RESET_KEYS)) ?? parseEpoch(pickNumber(payload, RESET_KEYS));
|
||||
const windows: UsageWindow[] = [
|
||||
{
|
||||
label: deriveWindowLabel(payload),
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { fetchJson } from "./provider-usage.fetch.shared.js";
|
||||
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
|
||||
import type {
|
||||
ProviderUsageSnapshot,
|
||||
UsageWindow,
|
||||
} from "./provider-usage.types.js";
|
||||
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
|
||||
|
||||
type ZaiUsageResponse = {
|
||||
success?: boolean;
|
||||
@@ -64,9 +61,7 @@ export async function fetchZaiUsage(
|
||||
|
||||
for (const limit of limits) {
|
||||
const percent = clampPercent(limit.percentage || 0);
|
||||
const nextReset = limit.nextResetTime
|
||||
? new Date(limit.nextResetTime).getTime()
|
||||
: undefined;
|
||||
const nextReset = limit.nextResetTime ? new Date(limit.nextResetTime).getTime() : undefined;
|
||||
let windowLabel = "Limit";
|
||||
if (limit.unit === 1) windowLabel = `${limit.number}d`;
|
||||
else if (limit.unit === 3) windowLabel = `${limit.number}h`;
|
||||
|
||||
@@ -25,9 +25,7 @@ function formatResetRemaining(targetMs?: number, now?: number): string | null {
|
||||
|
||||
function pickPrimaryWindow(windows: UsageWindow[]): UsageWindow | undefined {
|
||||
if (windows.length === 0) return undefined;
|
||||
return windows.reduce((best, next) =>
|
||||
next.usedPercent > best.usedPercent ? next : best,
|
||||
);
|
||||
return windows.reduce((best, next) => (next.usedPercent > best.usedPercent ? next : best));
|
||||
}
|
||||
|
||||
function formatWindowShort(window: UsageWindow, now?: number): string {
|
||||
@@ -58,10 +56,7 @@ export function formatUsageSummaryLine(
|
||||
return `📊 Usage: ${parts.join(" · ")}`;
|
||||
}
|
||||
|
||||
export function formatUsageReportLines(
|
||||
summary: UsageSummary,
|
||||
opts?: { now?: number },
|
||||
): string[] {
|
||||
export function formatUsageReportLines(summary: UsageSummary, opts?: { now?: number }): string[] {
|
||||
if (summary.providers.length === 0) {
|
||||
return ["Usage: no provider usage available."];
|
||||
}
|
||||
@@ -82,9 +77,7 @@ export function formatUsageReportLines(
|
||||
const remaining = clampPercent(100 - window.usedPercent);
|
||||
const reset = formatResetRemaining(window.resetAt, opts?.now);
|
||||
const resetSuffix = reset ? ` · resets ${reset}` : "";
|
||||
lines.push(
|
||||
` ${window.label}: ${remaining.toFixed(0)}% left${resetSuffix}`,
|
||||
);
|
||||
lines.push(` ${window.label}: ${remaining.toFixed(0)}% left${resetSuffix}`);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
type ProviderAuth,
|
||||
resolveProviderAuths,
|
||||
} from "./provider-usage.auth.js";
|
||||
import { type ProviderAuth, resolveProviderAuths } from "./provider-usage.auth.js";
|
||||
import {
|
||||
fetchClaudeUsage,
|
||||
fetchCodexUsage,
|
||||
@@ -58,19 +55,9 @@ export async function loadProviderUsageSummary(
|
||||
return await fetchCopilotUsage(auth.token, timeoutMs, fetchFn);
|
||||
case "google-gemini-cli":
|
||||
case "google-antigravity":
|
||||
return await fetchGeminiUsage(
|
||||
auth.token,
|
||||
timeoutMs,
|
||||
fetchFn,
|
||||
auth.provider,
|
||||
);
|
||||
return await fetchGeminiUsage(auth.token, timeoutMs, fetchFn, auth.provider);
|
||||
case "openai-codex":
|
||||
return await fetchCodexUsage(
|
||||
auth.token,
|
||||
auth.accountId,
|
||||
timeoutMs,
|
||||
fetchFn,
|
||||
);
|
||||
return await fetchCodexUsage(auth.token, auth.accountId, timeoutMs, fetchFn);
|
||||
case "minimax":
|
||||
return await fetchMinimaxUsage(auth.token, timeoutMs, fetchFn);
|
||||
case "zai":
|
||||
|
||||
@@ -23,9 +23,7 @@ export const usageProviders: UsageProviderId[] = [
|
||||
"zai",
|
||||
];
|
||||
|
||||
export function resolveUsageProviderId(
|
||||
provider?: string | null,
|
||||
): UsageProviderId | undefined {
|
||||
export function resolveUsageProviderId(provider?: string | null): UsageProviderId | undefined {
|
||||
if (!provider) return undefined;
|
||||
const normalized = normalizeProviderId(provider);
|
||||
return usageProviders.includes(normalized as UsageProviderId)
|
||||
@@ -44,11 +42,7 @@ export const ignoredErrors = new Set([
|
||||
export const clampPercent = (value: number) =>
|
||||
Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
|
||||
|
||||
export const withTimeout = async <T>(
|
||||
work: Promise<T>,
|
||||
ms: number,
|
||||
fallback: T,
|
||||
): Promise<T> => {
|
||||
export const withTimeout = async <T>(work: Promise<T>, ms: number, fallback: T): Promise<T> => {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
|
||||
@@ -2,10 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
listProfilesForProvider,
|
||||
} from "../agents/auth-profiles.js";
|
||||
import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js";
|
||||
import {
|
||||
formatUsageReportLines,
|
||||
formatUsageSummaryLine,
|
||||
@@ -76,58 +73,49 @@ describe("provider usage loading", () => {
|
||||
it("loads usage snapshots with injected auth", async () => {
|
||||
const makeResponse = (status: number, body: unknown): Response => {
|
||||
const payload = typeof body === "string" ? body : JSON.stringify(body);
|
||||
const headers =
|
||||
typeof body === "string"
|
||||
? undefined
|
||||
: { "Content-Type": "application/json" };
|
||||
const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" };
|
||||
return new Response(payload, { status, headers });
|
||||
};
|
||||
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(
|
||||
async (input) => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url;
|
||||
if (url.includes("api.anthropic.com")) {
|
||||
return makeResponse(200, {
|
||||
five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" },
|
||||
});
|
||||
}
|
||||
if (url.includes("api.z.ai")) {
|
||||
return makeResponse(200, {
|
||||
success: true,
|
||||
code: 200,
|
||||
data: {
|
||||
planName: "Pro",
|
||||
limits: [
|
||||
{
|
||||
type: "TOKENS_LIMIT",
|
||||
percentage: 25,
|
||||
unit: 3,
|
||||
number: 6,
|
||||
nextResetTime: "2026-01-07T06:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.includes("api.minimax.io/v1/coding_plan/remains")) {
|
||||
return makeResponse(200, {
|
||||
base_resp: { status_code: 0, status_msg: "ok" },
|
||||
data: {
|
||||
total: 200,
|
||||
remain: 50,
|
||||
reset_at: "2026-01-07T05:00:00Z",
|
||||
plan_name: "Coding Plan",
|
||||
},
|
||||
});
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
},
|
||||
);
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("api.anthropic.com")) {
|
||||
return makeResponse(200, {
|
||||
five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" },
|
||||
});
|
||||
}
|
||||
if (url.includes("api.z.ai")) {
|
||||
return makeResponse(200, {
|
||||
success: true,
|
||||
code: 200,
|
||||
data: {
|
||||
planName: "Pro",
|
||||
limits: [
|
||||
{
|
||||
type: "TOKENS_LIMIT",
|
||||
percentage: 25,
|
||||
unit: 3,
|
||||
number: 6,
|
||||
nextResetTime: "2026-01-07T06:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.includes("api.minimax.io/v1/coding_plan/remains")) {
|
||||
return makeResponse(200, {
|
||||
base_resp: { status_code: 0, status_msg: "ok" },
|
||||
data: {
|
||||
total: 200,
|
||||
remain: 50,
|
||||
reset_at: "2026-01-07T05:00:00Z",
|
||||
plan_name: "Coding Plan",
|
||||
},
|
||||
});
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const summary = await loadProviderUsageSummary({
|
||||
now: Date.UTC(2026, 0, 7, 0, 0, 0),
|
||||
@@ -182,42 +170,36 @@ describe("provider usage loading", () => {
|
||||
const store = ensureAuthProfileStore(agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
expect(listProfilesForProvider(store, "anthropic")).toContain(
|
||||
"anthropic:default",
|
||||
);
|
||||
expect(listProfilesForProvider(store, "anthropic")).toContain("anthropic:default");
|
||||
|
||||
const makeResponse = (status: number, body: unknown): Response => {
|
||||
const payload =
|
||||
typeof body === "string" ? body : JSON.stringify(body);
|
||||
const payload = typeof body === "string" ? body : JSON.stringify(body);
|
||||
const headers =
|
||||
typeof body === "string"
|
||||
? undefined
|
||||
: { "Content-Type": "application/json" };
|
||||
typeof body === "string" ? undefined : { "Content-Type": "application/json" };
|
||||
return new Response(payload, { status, headers });
|
||||
};
|
||||
|
||||
const mockFetch = vi.fn<
|
||||
Parameters<typeof fetch>,
|
||||
ReturnType<typeof fetch>
|
||||
>(async (input, init) => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url;
|
||||
if (url.includes("api.anthropic.com/api/oauth/usage")) {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers.Authorization).toBe("Bearer token-1");
|
||||
return makeResponse(200, {
|
||||
five_hour: {
|
||||
utilization: 20,
|
||||
resets_at: "2026-01-07T01:00:00Z",
|
||||
},
|
||||
});
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(
|
||||
async (input, init) => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url;
|
||||
if (url.includes("api.anthropic.com/api/oauth/usage")) {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers.Authorization).toBe("Bearer token-1");
|
||||
return makeResponse(200, {
|
||||
five_hour: {
|
||||
utilization: 20,
|
||||
resets_at: "2026-01-07T01:00:00Z",
|
||||
},
|
||||
});
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
},
|
||||
);
|
||||
|
||||
const summary = await loadProviderUsageSummary({
|
||||
now: Date.UTC(2026, 0, 7, 0, 0, 0),
|
||||
@@ -275,34 +257,30 @@ describe("provider usage loading", () => {
|
||||
);
|
||||
|
||||
const makeResponse = (status: number, body: unknown): Response => {
|
||||
const payload =
|
||||
typeof body === "string" ? body : JSON.stringify(body);
|
||||
const payload = typeof body === "string" ? body : JSON.stringify(body);
|
||||
const headers =
|
||||
typeof body === "string"
|
||||
? undefined
|
||||
: { "Content-Type": "application/json" };
|
||||
typeof body === "string" ? undefined : { "Content-Type": "application/json" };
|
||||
return new Response(payload, { status, headers });
|
||||
};
|
||||
|
||||
const mockFetch = vi.fn<
|
||||
Parameters<typeof fetch>,
|
||||
ReturnType<typeof fetch>
|
||||
>(async (input, init) => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url;
|
||||
if (url.includes("api.anthropic.com/api/oauth/usage")) {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers.Authorization).toBe("Bearer token-cli");
|
||||
return makeResponse(200, {
|
||||
five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" },
|
||||
});
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(
|
||||
async (input, init) => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url;
|
||||
if (url.includes("api.anthropic.com/api/oauth/usage")) {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers.Authorization).toBe("Bearer token-cli");
|
||||
return makeResponse(200, {
|
||||
five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" },
|
||||
});
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
},
|
||||
);
|
||||
|
||||
const summary = await loadProviderUsageSummary({
|
||||
now: Date.UTC(2026, 0, 7, 0, 0, 0),
|
||||
@@ -327,29 +305,19 @@ describe("provider usage loading", () => {
|
||||
const makeResponse = (status: number, body: unknown): Response => {
|
||||
const payload = typeof body === "string" ? body : JSON.stringify(body);
|
||||
const headers =
|
||||
typeof body === "string"
|
||||
? undefined
|
||||
: { "Content-Type": "application/json" };
|
||||
typeof body === "string" ? undefined : { "Content-Type": "application/json" };
|
||||
return new Response(payload, { status, headers });
|
||||
};
|
||||
|
||||
const mockFetch = vi.fn<
|
||||
Parameters<typeof fetch>,
|
||||
ReturnType<typeof fetch>
|
||||
>(async (input) => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url;
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("api.anthropic.com/api/oauth/usage")) {
|
||||
return makeResponse(403, {
|
||||
type: "error",
|
||||
error: {
|
||||
type: "permission_error",
|
||||
message:
|
||||
"OAuth token does not meet scope requirement user:profile",
|
||||
message: "OAuth token does not meet scope requirement user:profile",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -378,8 +346,7 @@ describe("provider usage loading", () => {
|
||||
expect(claude?.windows.some((w) => w.label === "5h")).toBe(true);
|
||||
expect(claude?.windows.some((w) => w.label === "Week")).toBe(true);
|
||||
} finally {
|
||||
if (cookieSnapshot === undefined)
|
||||
delete process.env.CLAUDE_AI_SESSION_KEY;
|
||||
if (cookieSnapshot === undefined) delete process.env.CLAUDE_AI_SESSION_KEY;
|
||||
else process.env.CLAUDE_AI_SESSION_KEY = cookieSnapshot;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
export {
|
||||
formatUsageReportLines,
|
||||
formatUsageSummaryLine,
|
||||
} from "./provider-usage.format.js";
|
||||
export { formatUsageReportLines, formatUsageSummaryLine } from "./provider-usage.format.js";
|
||||
export { loadProviderUsageSummary } from "./provider-usage.load.js";
|
||||
export { resolveUsageProviderId } from "./provider-usage.shared.js";
|
||||
export type {
|
||||
|
||||
@@ -44,12 +44,9 @@ export type RestartSentinel = {
|
||||
|
||||
const SENTINEL_FILENAME = "restart-sentinel.json";
|
||||
|
||||
export const DOCTOR_NONINTERACTIVE_HINT =
|
||||
"Run: clawdbot doctor --non-interactive";
|
||||
export const DOCTOR_NONINTERACTIVE_HINT = "Run: clawdbot doctor --non-interactive";
|
||||
|
||||
export function resolveRestartSentinelPath(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
export function resolveRestartSentinelPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return path.join(resolveStateDir(env), SENTINEL_FILENAME);
|
||||
}
|
||||
|
||||
@@ -97,15 +94,11 @@ export async function consumeRestartSentinel(
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function formatRestartSentinelMessage(
|
||||
payload: RestartSentinelPayload,
|
||||
): string {
|
||||
export function formatRestartSentinelMessage(payload: RestartSentinelPayload): string {
|
||||
return `GatewayRestart:\n${JSON.stringify(payload, null, 2)}`;
|
||||
}
|
||||
|
||||
export function summarizeRestartSentinel(
|
||||
payload: RestartSentinelPayload,
|
||||
): string {
|
||||
export function summarizeRestartSentinel(payload: RestartSentinelPayload): string {
|
||||
const kind = payload.kind;
|
||||
const status = payload.status;
|
||||
const mode = payload.stats?.mode ? ` (${payload.stats.mode})` : "";
|
||||
|
||||
@@ -20,8 +20,7 @@ function formatSpawnDetail(result: {
|
||||
stderr?: string | Buffer | null;
|
||||
}): string {
|
||||
const clean = (value: string | Buffer | null | undefined) => {
|
||||
const text =
|
||||
typeof value === "string" ? value : value ? value.toString() : "";
|
||||
const text = typeof value === "string" ? value : value ? value.toString() : "";
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
};
|
||||
if (result.error) {
|
||||
@@ -94,8 +93,7 @@ export function triggerClawdbotRestart(): RestartAttempt {
|
||||
const label =
|
||||
process.env.CLAWDBOT_LAUNCHD_LABEL ||
|
||||
resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE);
|
||||
const uid =
|
||||
typeof process.getuid === "function" ? process.getuid() : undefined;
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
||||
const target = uid !== undefined ? `gui/${uid}/${label}` : label;
|
||||
const args = ["kickstart", "-k", target];
|
||||
tried.push(`launchctl ${args.join(" ")}`);
|
||||
|
||||
@@ -3,10 +3,7 @@ import { RateLimitError } from "@buape/carbon";
|
||||
import { formatErrorMessage } from "./errors.js";
|
||||
import { type RetryConfig, resolveRetryConfig, retryAsync } from "./retry.js";
|
||||
|
||||
export type RetryRunner = <T>(
|
||||
fn: () => Promise<T>,
|
||||
label?: string,
|
||||
) => Promise<T>;
|
||||
export type RetryRunner = <T>(fn: () => Promise<T>, label?: string) => Promise<T>;
|
||||
|
||||
export const DISCORD_RETRY_DEFAULTS = {
|
||||
attempts: 3,
|
||||
@@ -22,8 +19,7 @@ export const TELEGRAM_RETRY_DEFAULTS = {
|
||||
jitter: 0.1,
|
||||
};
|
||||
|
||||
const TELEGRAM_RETRY_RE =
|
||||
/429|timeout|connect|reset|closed|unavailable|temporarily/i;
|
||||
const TELEGRAM_RETRY_RE = /429|timeout|connect|reset|closed|unavailable|temporarily/i;
|
||||
|
||||
function getTelegramRetryAfterMs(err: unknown): number | undefined {
|
||||
if (!err || typeof err !== "object") return undefined;
|
||||
@@ -39,16 +35,10 @@ function getTelegramRetryAfterMs(err: unknown): number | undefined {
|
||||
parameters?: { retry_after?: unknown };
|
||||
}
|
||||
).parameters?.retry_after
|
||||
: "error" in err &&
|
||||
err.error &&
|
||||
typeof err.error === "object" &&
|
||||
"parameters" in err.error
|
||||
? (err.error as { parameters?: { retry_after?: unknown } }).parameters
|
||||
?.retry_after
|
||||
: "error" in err && err.error && typeof err.error === "object" && "parameters" in err.error
|
||||
? (err.error as { parameters?: { retry_after?: unknown } }).parameters?.retry_after
|
||||
: undefined;
|
||||
return typeof candidate === "number" && Number.isFinite(candidate)
|
||||
? candidate * 1000
|
||||
: undefined;
|
||||
return typeof candidate === "number" && Number.isFinite(candidate) ? candidate * 1000 : undefined;
|
||||
}
|
||||
|
||||
export function createDiscordRetryRunner(params: {
|
||||
@@ -65,8 +55,7 @@ export function createDiscordRetryRunner(params: {
|
||||
...retryConfig,
|
||||
label,
|
||||
shouldRetry: (err) => err instanceof RateLimitError,
|
||||
retryAfterMs: (err) =>
|
||||
err instanceof RateLimitError ? err.retryAfter * 1000 : undefined,
|
||||
retryAfterMs: (err) => (err instanceof RateLimitError ? err.retryAfter * 1000 : undefined),
|
||||
onRetry: params.verbose
|
||||
? (info) => {
|
||||
const labelText = info.label ?? "request";
|
||||
|
||||
@@ -11,10 +11,7 @@ describe("retryAsync", () => {
|
||||
});
|
||||
|
||||
it("retries then succeeds", async () => {
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("fail1"))
|
||||
.mockResolvedValueOnce("ok");
|
||||
const fn = vi.fn().mockRejectedValueOnce(new Error("fail1")).mockResolvedValueOnce("ok");
|
||||
const result = await retryAsync(fn, 3, 1);
|
||||
expect(result).toBe("ok");
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
@@ -28,17 +25,12 @@ describe("retryAsync", () => {
|
||||
|
||||
it("stops when shouldRetry returns false", async () => {
|
||||
const fn = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
await expect(
|
||||
retryAsync(fn, { attempts: 3, shouldRetry: () => false }),
|
||||
).rejects.toThrow("boom");
|
||||
await expect(retryAsync(fn, { attempts: 3, shouldRetry: () => false })).rejects.toThrow("boom");
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onRetry before retrying", async () => {
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValueOnce("ok");
|
||||
const fn = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce("ok");
|
||||
const onRetry = vi.fn();
|
||||
const res = await retryAsync(fn, {
|
||||
attempts: 2,
|
||||
@@ -47,25 +39,20 @@ describe("retryAsync", () => {
|
||||
onRetry,
|
||||
});
|
||||
expect(res).toBe("ok");
|
||||
expect(onRetry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ attempt: 1, maxAttempts: 2 }),
|
||||
);
|
||||
expect(onRetry).toHaveBeenCalledWith(expect.objectContaining({ attempt: 1, maxAttempts: 2 }));
|
||||
});
|
||||
|
||||
it("clamps attempts to at least 1", async () => {
|
||||
const fn = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
await expect(
|
||||
retryAsync(fn, { attempts: 0, minDelayMs: 0, maxDelayMs: 0 }),
|
||||
).rejects.toThrow("boom");
|
||||
await expect(retryAsync(fn, { attempts: 0, minDelayMs: 0, maxDelayMs: 0 })).rejects.toThrow(
|
||||
"boom",
|
||||
);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses retryAfterMs when provided", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValueOnce("ok");
|
||||
const fn = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce("ok");
|
||||
const delays: number[] = [];
|
||||
const promise = retryAsync(fn, {
|
||||
attempts: 2,
|
||||
@@ -83,10 +70,7 @@ describe("retryAsync", () => {
|
||||
|
||||
it("clamps retryAfterMs to maxDelayMs", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValueOnce("ok");
|
||||
const fn = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce("ok");
|
||||
const delays: number[] = [];
|
||||
const promise = retryAsync(fn, {
|
||||
attempts: 2,
|
||||
|
||||
@@ -32,12 +32,7 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const asFiniteNumber = (value: unknown): number | undefined =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
|
||||
const clampNumber = (
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
min?: number,
|
||||
max?: number,
|
||||
) => {
|
||||
const clampNumber = (value: unknown, fallback: number, min?: number, max?: number) => {
|
||||
const next = asFiniteNumber(value);
|
||||
if (next === undefined) return fallback;
|
||||
const floor = typeof min === "number" ? min : Number.NEGATIVE_INFINITY;
|
||||
@@ -49,10 +44,7 @@ export function resolveRetryConfig(
|
||||
defaults: Required<RetryConfig> = DEFAULT_RETRY_CONFIG,
|
||||
overrides?: RetryConfig,
|
||||
): Required<RetryConfig> {
|
||||
const attempts = Math.max(
|
||||
1,
|
||||
Math.round(clampNumber(overrides?.attempts, defaults.attempts, 1)),
|
||||
);
|
||||
const attempts = Math.max(1, Math.round(clampNumber(overrides?.attempts, defaults.attempts, 1)));
|
||||
const minDelayMs = Math.max(
|
||||
0,
|
||||
Math.round(clampNumber(overrides?.minDelayMs, defaults.minDelayMs, 0)),
|
||||
@@ -113,8 +105,7 @@ export async function retryAsync<T>(
|
||||
if (attempt >= maxAttempts || !shouldRetry(err, attempt)) break;
|
||||
|
||||
const retryAfterMs = options.retryAfterMs?.(err);
|
||||
const hasRetryAfter =
|
||||
typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs);
|
||||
const hasRetryAfter = typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs);
|
||||
const baseDelay = hasRetryAfter
|
||||
? Math.max(retryAfterMs, minDelayMs)
|
||||
: minDelayMs * 2 ** (attempt - 1);
|
||||
|
||||
@@ -17,24 +17,15 @@ describe("runtime-guard", () => {
|
||||
});
|
||||
|
||||
it("compares versions correctly", () => {
|
||||
expect(
|
||||
isAtLeast(
|
||||
{ major: 22, minor: 0, patch: 0 },
|
||||
{ major: 22, minor: 0, patch: 0 },
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAtLeast(
|
||||
{ major: 22, minor: 1, patch: 0 },
|
||||
{ major: 22, minor: 0, patch: 0 },
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAtLeast(
|
||||
{ major: 21, minor: 9, patch: 0 },
|
||||
{ major: 22, minor: 0, patch: 0 },
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isAtLeast({ major: 22, minor: 0, patch: 0 }, { major: 22, minor: 0, patch: 0 })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isAtLeast({ major: 22, minor: 1, patch: 0 }, { major: 22, minor: 0, patch: 0 })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isAtLeast({ major: 21, minor: 9, patch: 0 }, { major: 22, minor: 0, patch: 0 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("validates runtime thresholds", () => {
|
||||
@@ -71,9 +62,7 @@ describe("runtime-guard", () => {
|
||||
pathEnv: "/usr/bin",
|
||||
};
|
||||
expect(() => assertSupportedRuntime(runtime, details)).toThrow("exit");
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("requires Node"),
|
||||
);
|
||||
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("requires Node"));
|
||||
});
|
||||
|
||||
it("returns silently when runtime meets requirements", () => {
|
||||
|
||||
@@ -70,9 +70,7 @@ export function assertSupportedRuntime(
|
||||
|
||||
const versionLabel = details.version ?? "unknown";
|
||||
const runtimeLabel =
|
||||
details.kind === "unknown"
|
||||
? "unknown runtime"
|
||||
: `${details.kind} ${versionLabel}`;
|
||||
details.kind === "unknown" ? "unknown runtime" : `${details.kind} ${versionLabel}`;
|
||||
const execLabel = details.execPath ?? "unknown";
|
||||
|
||||
runtime.error(
|
||||
|
||||
@@ -9,21 +9,13 @@ import {
|
||||
describe("shell env fallback", () => {
|
||||
it("is disabled by default", () => {
|
||||
expect(shouldEnableShellEnvFallback({} as NodeJS.ProcessEnv)).toBe(false);
|
||||
expect(shouldEnableShellEnvFallback({ CLAWDBOT_LOAD_SHELL_ENV: "0" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(shouldEnableShellEnvFallback({ CLAWDBOT_LOAD_SHELL_ENV: "1" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(shouldEnableShellEnvFallback({ CLAWDBOT_LOAD_SHELL_ENV: "0" })).toBe(false);
|
||||
expect(shouldEnableShellEnvFallback({ CLAWDBOT_LOAD_SHELL_ENV: "1" })).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves timeout from env with default fallback", () => {
|
||||
expect(resolveShellEnvFallbackTimeoutMs({} as NodeJS.ProcessEnv)).toBe(
|
||||
15000,
|
||||
);
|
||||
expect(
|
||||
resolveShellEnvFallbackTimeoutMs({ CLAWDBOT_SHELL_ENV_TIMEOUT_MS: "42" }),
|
||||
).toBe(42);
|
||||
expect(resolveShellEnvFallbackTimeoutMs({} as NodeJS.ProcessEnv)).toBe(15000);
|
||||
expect(resolveShellEnvFallbackTimeoutMs({ CLAWDBOT_SHELL_ENV_TIMEOUT_MS: "42" })).toBe(42);
|
||||
expect(
|
||||
resolveShellEnvFallbackTimeoutMs({
|
||||
CLAWDBOT_SHELL_ENV_TIMEOUT_MS: "nope",
|
||||
@@ -39,9 +31,7 @@ describe("shell env fallback", () => {
|
||||
enabled: true,
|
||||
env,
|
||||
expectedKeys: ["OPENAI_API_KEY", "DISCORD_BOT_TOKEN"],
|
||||
exec: exec as unknown as Parameters<
|
||||
typeof loadShellEnvFallback
|
||||
>[0]["exec"],
|
||||
exec: exec as unknown as Parameters<typeof loadShellEnvFallback>[0]["exec"],
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
@@ -52,17 +42,13 @@ describe("shell env fallback", () => {
|
||||
|
||||
it("imports expected keys without overriding existing env", () => {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
const exec = vi.fn(() =>
|
||||
Buffer.from("OPENAI_API_KEY=from-shell\0DISCORD_BOT_TOKEN=discord\0"),
|
||||
);
|
||||
const exec = vi.fn(() => Buffer.from("OPENAI_API_KEY=from-shell\0DISCORD_BOT_TOKEN=discord\0"));
|
||||
|
||||
const res1 = loadShellEnvFallback({
|
||||
enabled: true,
|
||||
env,
|
||||
expectedKeys: ["OPENAI_API_KEY", "DISCORD_BOT_TOKEN"],
|
||||
exec: exec as unknown as Parameters<
|
||||
typeof loadShellEnvFallback
|
||||
>[0]["exec"],
|
||||
exec: exec as unknown as Parameters<typeof loadShellEnvFallback>[0]["exec"],
|
||||
});
|
||||
|
||||
expect(res1.ok).toBe(true);
|
||||
@@ -78,9 +64,7 @@ describe("shell env fallback", () => {
|
||||
enabled: true,
|
||||
env,
|
||||
expectedKeys: ["OPENAI_API_KEY", "DISCORD_BOT_TOKEN"],
|
||||
exec: exec2 as unknown as Parameters<
|
||||
typeof loadShellEnvFallback
|
||||
>[0]["exec"],
|
||||
exec: exec2 as unknown as Parameters<typeof loadShellEnvFallback>[0]["exec"],
|
||||
});
|
||||
|
||||
expect(res2.ok).toBe(true);
|
||||
|
||||
@@ -29,9 +29,7 @@ export type ShellEnvFallbackOptions = {
|
||||
exec?: typeof execFileSync;
|
||||
};
|
||||
|
||||
export function loadShellEnvFallback(
|
||||
opts: ShellEnvFallbackOptions,
|
||||
): ShellEnvFallbackResult {
|
||||
export function loadShellEnvFallback(opts: ShellEnvFallbackOptions): ShellEnvFallbackResult {
|
||||
const logger = opts.logger ?? console;
|
||||
const exec = opts.exec ?? execFileSync;
|
||||
|
||||
@@ -40,9 +38,7 @@ export function loadShellEnvFallback(
|
||||
return { ok: true, applied: [], skippedReason: "disabled" };
|
||||
}
|
||||
|
||||
const hasAnyKey = opts.expectedKeys.some((key) =>
|
||||
Boolean(opts.env[key]?.trim()),
|
||||
);
|
||||
const hasAnyKey = opts.expectedKeys.some((key) => Boolean(opts.env[key]?.trim()));
|
||||
if (hasAnyKey) {
|
||||
lastAppliedKeys = [];
|
||||
return { ok: true, applied: [], skippedReason: "already-has-keys" };
|
||||
@@ -100,9 +96,7 @@ export function shouldEnableShellEnvFallback(env: NodeJS.ProcessEnv): boolean {
|
||||
return isTruthy(env.CLAWDBOT_LOAD_SHELL_ENV);
|
||||
}
|
||||
|
||||
export function resolveShellEnvFallbackTimeoutMs(
|
||||
env: NodeJS.ProcessEnv,
|
||||
): number {
|
||||
export function resolveShellEnvFallbackTimeoutMs(env: NodeJS.ProcessEnv): number {
|
||||
const raw = env.CLAWDBOT_SHELL_ENV_TIMEOUT_MS?.trim();
|
||||
if (!raw) return DEFAULT_TIMEOUT_MS;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
|
||||
@@ -79,10 +79,7 @@ async function canConnectLocal(port: number): Promise<boolean> {
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForLocalListener(
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
async function waitForLocalListener(port: number, timeoutMs: number): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (await canConnectLocal(port)) return;
|
||||
@@ -175,20 +172,14 @@ export async function startSshPortForward(opts: {
|
||||
waitForLocalListener(localPort, Math.max(250, opts.timeoutMs)),
|
||||
new Promise<void>((_, reject) => {
|
||||
child.once("exit", (code, signal) => {
|
||||
reject(
|
||||
new Error(
|
||||
`ssh exited (${code ?? "null"}${signal ? `/${signal}` : ""})`,
|
||||
),
|
||||
);
|
||||
reject(new Error(`ssh exited (${code ?? "null"}${signal ? `/${signal}` : ""})`));
|
||||
});
|
||||
}),
|
||||
]);
|
||||
} catch (err) {
|
||||
await stop();
|
||||
const suffix = stderr.length > 0 ? `\n${stderr.join("\n")}` : "";
|
||||
throw new Error(
|
||||
`${err instanceof Error ? err.message : String(err)}${suffix}`,
|
||||
);
|
||||
throw new Error(`${err instanceof Error ? err.message : String(err)}${suffix}`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -101,8 +101,7 @@ function pickLatestLegacyDirectEntry(
|
||||
}
|
||||
|
||||
function normalizeSessionEntry(entry: SessionEntryLike): SessionEntry | null {
|
||||
const sessionId =
|
||||
typeof entry.sessionId === "string" ? entry.sessionId : null;
|
||||
const sessionId = typeof entry.sessionId === "string" ? entry.sessionId : null;
|
||||
if (!sessionId) return null;
|
||||
const updatedAt =
|
||||
typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt)
|
||||
@@ -153,12 +152,7 @@ export async function detectLegacyStateMigrations(params: {
|
||||
|
||||
const sessionsLegacyDir = path.join(stateDir, "sessions");
|
||||
const sessionsLegacyStorePath = path.join(sessionsLegacyDir, "sessions.json");
|
||||
const sessionsTargetDir = path.join(
|
||||
stateDir,
|
||||
"agents",
|
||||
targetAgentId,
|
||||
"sessions",
|
||||
);
|
||||
const sessionsTargetDir = path.join(stateDir, "agents", targetAgentId, "sessions");
|
||||
const sessionsTargetStorePath = path.join(sessionsTargetDir, "sessions.json");
|
||||
const legacySessionEntries = safeReadDir(sessionsLegacyDir);
|
||||
const hasLegacySessions =
|
||||
@@ -169,11 +163,7 @@ export async function detectLegacyStateMigrations(params: {
|
||||
const targetAgentDir = path.join(stateDir, "agents", targetAgentId, "agent");
|
||||
const hasLegacyAgentDir = existsDir(legacyAgentDir);
|
||||
|
||||
const targetWhatsAppAuthDir = path.join(
|
||||
oauthDir,
|
||||
"whatsapp",
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
);
|
||||
const targetWhatsAppAuthDir = path.join(oauthDir, "whatsapp", DEFAULT_ACCOUNT_ID);
|
||||
const hasLegacyWhatsAppAuth =
|
||||
fileExists(path.join(oauthDir, "creds.json")) &&
|
||||
!fileExists(path.join(targetWhatsAppAuthDir, "creds.json"));
|
||||
@@ -186,9 +176,7 @@ export async function detectLegacyStateMigrations(params: {
|
||||
preview.push(`- Agent dir: ${legacyAgentDir} → ${targetAgentDir}`);
|
||||
}
|
||||
if (hasLegacyWhatsAppAuth) {
|
||||
preview.push(
|
||||
`- WhatsApp auth: ${oauthDir} → ${targetWhatsAppAuthDir} (keep oauth.json)`,
|
||||
);
|
||||
preview.push(`- WhatsApp auth: ${oauthDir} → ${targetWhatsAppAuthDir} (keep oauth.json)`);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -277,9 +265,7 @@ async function migrateLegacySessions(
|
||||
normalized[key] = normalizedEntry;
|
||||
}
|
||||
await saveSessionStore(detected.sessions.targetStorePath, normalized);
|
||||
changes.push(
|
||||
`Merged sessions store → ${detected.sessions.targetStorePath}`,
|
||||
);
|
||||
changes.push(`Merged sessions store → ${detected.sessions.targetStorePath}`);
|
||||
}
|
||||
|
||||
const entries = safeReadDir(detected.sessions.legacyDir);
|
||||
@@ -291,9 +277,7 @@ async function migrateLegacySessions(
|
||||
if (fileExists(to)) continue;
|
||||
try {
|
||||
fs.renameSync(from, to);
|
||||
changes.push(
|
||||
`Moved ${entry.name} → agents/${detected.targetAgentId}/sessions`,
|
||||
);
|
||||
changes.push(`Moved ${entry.name} → agents/${detected.targetAgentId}/sessions`);
|
||||
} catch (err) {
|
||||
warnings.push(`Failed moving ${from}: ${String(err)}`);
|
||||
}
|
||||
@@ -310,9 +294,7 @@ async function migrateLegacySessions(
|
||||
}
|
||||
|
||||
removeDirIfEmpty(detected.sessions.legacyDir);
|
||||
const legacyLeft = safeReadDir(detected.sessions.legacyDir).filter((e) =>
|
||||
e.isFile(),
|
||||
);
|
||||
const legacyLeft = safeReadDir(detected.sessions.legacyDir).filter((e) => e.isFile());
|
||||
if (legacyLeft.length > 0) {
|
||||
const backupDir = `${detected.sessions.legacyDir}.legacy-${now()}`;
|
||||
try {
|
||||
@@ -343,9 +325,7 @@ export async function migrateLegacyAgentDir(
|
||||
if (fs.existsSync(to)) continue;
|
||||
try {
|
||||
fs.renameSync(from, to);
|
||||
changes.push(
|
||||
`Moved agent file ${entry.name} → agents/${detected.targetAgentId}/agent`,
|
||||
);
|
||||
changes.push(`Moved agent file ${entry.name} → agents/${detected.targetAgentId}/agent`);
|
||||
} catch (err) {
|
||||
warnings.push(`Failed moving ${from}: ${String(err)}`);
|
||||
}
|
||||
@@ -408,16 +388,8 @@ export async function runLegacyStateMigrations(params: {
|
||||
const agentDir = await migrateLegacyAgentDir(detected, now);
|
||||
const whatsappAuth = await migrateLegacyWhatsAppAuth(detected);
|
||||
return {
|
||||
changes: [
|
||||
...sessions.changes,
|
||||
...agentDir.changes,
|
||||
...whatsappAuth.changes,
|
||||
],
|
||||
warnings: [
|
||||
...sessions.warnings,
|
||||
...agentDir.warnings,
|
||||
...whatsappAuth.warnings,
|
||||
],
|
||||
changes: [...sessions.changes, ...agentDir.changes, ...whatsappAuth.changes],
|
||||
warnings: [...sessions.warnings, ...agentDir.warnings, ...whatsappAuth.warnings],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -475,17 +447,11 @@ export async function autoMigrateLegacyState(params: {
|
||||
|
||||
const logger = params.log ?? createSubsystemLogger("state-migrations");
|
||||
if (changes.length > 0) {
|
||||
logger.info(
|
||||
`Auto-migrated legacy state:\n${changes
|
||||
.map((entry) => `- ${entry}`)
|
||||
.join("\n")}`,
|
||||
);
|
||||
logger.info(`Auto-migrated legacy state:\n${changes.map((entry) => `- ${entry}`).join("\n")}`);
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
logger.warn(
|
||||
`Legacy state migration warnings:\n${warnings
|
||||
.map((entry) => `- ${entry}`)
|
||||
.join("\n")}`,
|
||||
`Legacy state migration warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,7 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { prependSystemEvents } from "../auto-reply/reply/session-updates.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveMainSessionKey } from "../config/sessions.js";
|
||||
import {
|
||||
enqueueSystemEvent,
|
||||
peekSystemEvents,
|
||||
resetSystemEventsForTest,
|
||||
} from "./system-events.js";
|
||||
import { enqueueSystemEvent, peekSystemEvents, resetSystemEventsForTest } from "./system-events.js";
|
||||
|
||||
const cfg = {} as unknown as ClawdbotConfig;
|
||||
const mainKey = resolveMainSessionKey(cfg);
|
||||
@@ -24,9 +20,7 @@ describe("system events (session routing)", () => {
|
||||
});
|
||||
|
||||
expect(peekSystemEvents(mainKey)).toEqual([]);
|
||||
expect(peekSystemEvents("discord:group:123")).toEqual([
|
||||
"Discord reaction added: ✅",
|
||||
]);
|
||||
expect(peekSystemEvents("discord:group:123")).toEqual(["Discord reaction added: ✅"]);
|
||||
|
||||
const main = await prependSystemEvents({
|
||||
cfg,
|
||||
@@ -36,9 +30,7 @@ describe("system events (session routing)", () => {
|
||||
prefixedBodyBase: "hello",
|
||||
});
|
||||
expect(main).toBe("hello");
|
||||
expect(peekSystemEvents("discord:group:123")).toEqual([
|
||||
"Discord reaction added: ✅",
|
||||
]);
|
||||
expect(peekSystemEvents("discord:group:123")).toEqual(["Discord reaction added: ✅"]);
|
||||
|
||||
const discord = await prependSystemEvents({
|
||||
cfg,
|
||||
@@ -47,15 +39,11 @@ describe("system events (session routing)", () => {
|
||||
isNewSession: false,
|
||||
prefixedBodyBase: "hi",
|
||||
});
|
||||
expect(discord).toMatch(
|
||||
/^System: \[[^\]]+\] Discord reaction added: ✅\n\nhi$/,
|
||||
);
|
||||
expect(discord).toMatch(/^System: \[[^\]]+\] Discord reaction added: ✅\n\nhi$/);
|
||||
expect(peekSystemEvents("discord:group:123")).toEqual([]);
|
||||
});
|
||||
|
||||
it("requires an explicit session key", () => {
|
||||
expect(() =>
|
||||
enqueueSystemEvent("Node: Mac Studio", { sessionKey: " " }),
|
||||
).toThrow("sessionKey");
|
||||
expect(() => enqueueSystemEvent("Node: Mac Studio", { sessionKey: " " })).toThrow("sessionKey");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
listSystemPresence,
|
||||
updateSystemPresence,
|
||||
upsertPresence,
|
||||
} from "./system-presence.js";
|
||||
import { listSystemPresence, updateSystemPresence, upsertPresence } from "./system-presence.js";
|
||||
|
||||
describe("system-presence", () => {
|
||||
it("dedupes entries across sources by case-insensitive instanceId key", () => {
|
||||
|
||||
@@ -56,10 +56,7 @@ function resolvePrimaryIPv4(): string | undefined {
|
||||
function initSelfPresence() {
|
||||
const host = os.hostname();
|
||||
const ip = resolvePrimaryIPv4() ?? undefined;
|
||||
const version =
|
||||
process.env.CLAWDBOT_VERSION ??
|
||||
process.env.npm_package_version ??
|
||||
"unknown";
|
||||
const version = process.env.CLAWDBOT_VERSION ?? process.env.npm_package_version ?? "unknown";
|
||||
const modelIdentifier = (() => {
|
||||
const p = os.platform();
|
||||
if (p === "darwin") {
|
||||
@@ -146,9 +143,7 @@ function parsePresence(text: string): SystemPresence {
|
||||
host: host.trim(),
|
||||
ip: ip.trim(),
|
||||
version: version.trim(),
|
||||
lastInputSeconds: Number.isFinite(lastInputSeconds)
|
||||
? lastInputSeconds
|
||||
: undefined,
|
||||
lastInputSeconds: Number.isFinite(lastInputSeconds) ? lastInputSeconds : undefined,
|
||||
mode: mode.trim(),
|
||||
reason,
|
||||
text: trimmed,
|
||||
@@ -171,9 +166,7 @@ type SystemPresencePayload = {
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export function updateSystemPresence(
|
||||
payload: SystemPresencePayload,
|
||||
): SystemPresenceUpdate {
|
||||
export function updateSystemPresence(payload: SystemPresencePayload): SystemPresenceUpdate {
|
||||
ensureSelfPresence();
|
||||
const parsed = parsePresence(payload.text);
|
||||
const key =
|
||||
@@ -196,9 +189,7 @@ export function updateSystemPresence(
|
||||
modelIdentifier: payload.modelIdentifier ?? existing.modelIdentifier,
|
||||
mode: payload.mode ?? parsed.mode ?? existing.mode,
|
||||
lastInputSeconds:
|
||||
payload.lastInputSeconds ??
|
||||
parsed.lastInputSeconds ??
|
||||
existing.lastInputSeconds,
|
||||
payload.lastInputSeconds ?? parsed.lastInputSeconds ?? existing.lastInputSeconds,
|
||||
reason: payload.reason ?? parsed.reason ?? existing.reason,
|
||||
instanceId: payload.instanceId ?? parsed.instanceId ?? existing.instanceId,
|
||||
text: payload.text || parsed.text || existing.text,
|
||||
@@ -228,8 +219,7 @@ export function updateSystemPresence(
|
||||
|
||||
export function upsertPresence(key: string, presence: Partial<SystemPresence>) {
|
||||
ensureSelfPresence();
|
||||
const normalizedKey =
|
||||
normalizePresenceKey(key) ?? os.hostname().toLowerCase();
|
||||
const normalizedKey = normalizePresenceKey(key) ?? os.hostname().toLowerCase();
|
||||
const existing = entries.get(normalizedKey) ?? ({} as SystemPresence);
|
||||
const merged: SystemPresence = {
|
||||
...existing,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ensureGoInstalled,
|
||||
ensureTailscaledInstalled,
|
||||
getTailnetHostname,
|
||||
} from "./tailscale.js";
|
||||
import { ensureGoInstalled, ensureTailscaledInstalled, getTailnetHostname } from "./tailscale.js";
|
||||
|
||||
describe("tailscale helpers", () => {
|
||||
it("parses DNS name from tailscale status", async () => {
|
||||
@@ -26,10 +22,7 @@ describe("tailscale helpers", () => {
|
||||
});
|
||||
|
||||
it("ensureGoInstalled installs when missing and user agrees", async () => {
|
||||
const exec = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("no go"))
|
||||
.mockResolvedValue({}); // brew install go
|
||||
const exec = vi.fn().mockRejectedValueOnce(new Error("no go")).mockResolvedValue({}); // brew install go
|
||||
const prompt = vi.fn().mockResolvedValue(true);
|
||||
const runtime = {
|
||||
error: vi.fn(),
|
||||
@@ -43,10 +36,7 @@ describe("tailscale helpers", () => {
|
||||
});
|
||||
|
||||
it("ensureTailscaledInstalled installs when missing and user agrees", async () => {
|
||||
const exec = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("missing"))
|
||||
.mockResolvedValue({});
|
||||
const exec = vi.fn().mockRejectedValueOnce(new Error("missing")).mockResolvedValue({});
|
||||
const prompt = vi.fn().mockResolvedValue(true);
|
||||
const runtime = {
|
||||
error: vi.fn(),
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { promptYesNo } from "../cli/prompt.js";
|
||||
import {
|
||||
danger,
|
||||
info,
|
||||
logVerbose,
|
||||
shouldLogVerbose,
|
||||
warn,
|
||||
} from "../globals.js";
|
||||
import { danger, info, logVerbose, shouldLogVerbose, warn } from "../globals.js";
|
||||
import { runExec } from "../process/exec.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||
@@ -39,9 +33,7 @@ export async function findTailscaleBinary(): Promise<string | null> {
|
||||
// Use Promise.race with runExec to implement timeout
|
||||
await Promise.race([
|
||||
runExec(path, ["--version"], { timeoutMs: 3000 }),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("timeout")), 3000),
|
||||
),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("timeout")), 3000)),
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
@@ -95,9 +87,7 @@ export async function findTailscaleBinary(): Promise<string | null> {
|
||||
const candidates = stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) =>
|
||||
line.includes("/Tailscale.app/Contents/MacOS/Tailscale"),
|
||||
);
|
||||
.filter((line) => line.includes("/Tailscale.app/Contents/MacOS/Tailscale"));
|
||||
for (const candidate of candidates) {
|
||||
if (await checkBinary(candidate)) {
|
||||
return candidate;
|
||||
@@ -110,10 +100,7 @@ export async function findTailscaleBinary(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getTailnetHostname(
|
||||
exec: typeof runExec = runExec,
|
||||
detectedBinary?: string,
|
||||
) {
|
||||
export async function getTailnetHostname(exec: typeof runExec = runExec, detectedBinary?: string) {
|
||||
// Derive tailnet hostname (or IP fallback) from tailscale status JSON.
|
||||
const candidates = detectedBinary
|
||||
? [detectedBinary]
|
||||
@@ -132,10 +119,7 @@ export async function getTailnetHostname(
|
||||
typeof parsed.Self === "object" && parsed.Self !== null
|
||||
? (parsed.Self as Record<string, unknown>)
|
||||
: undefined;
|
||||
const dns =
|
||||
typeof self?.DNSName === "string"
|
||||
? (self.DNSName as string)
|
||||
: undefined;
|
||||
const dns = typeof self?.DNSName === "string" ? (self.DNSName as string) : undefined;
|
||||
const ips = Array.isArray(self?.TailscaleIPs)
|
||||
? ((parsed.Self as { TailscaleIPs?: string[] }).TailscaleIPs ?? [])
|
||||
: [];
|
||||
@@ -230,16 +214,10 @@ export async function ensureFunnel(
|
||||
// Ensure Funnel is enabled and publish the webhook port.
|
||||
try {
|
||||
const tailscaleBin = await getTailscaleBinary();
|
||||
const statusOut = (
|
||||
await exec(tailscaleBin, ["funnel", "status", "--json"])
|
||||
).stdout.trim();
|
||||
const parsed = statusOut
|
||||
? (JSON.parse(statusOut) as Record<string, unknown>)
|
||||
: {};
|
||||
const statusOut = (await exec(tailscaleBin, ["funnel", "status", "--json"])).stdout.trim();
|
||||
const parsed = statusOut ? (JSON.parse(statusOut) as Record<string, unknown>) : {};
|
||||
if (!parsed || Object.keys(parsed).length === 0) {
|
||||
runtime.error(
|
||||
danger("Tailscale Funnel is not enabled on this tailnet/device."),
|
||||
);
|
||||
runtime.error(danger("Tailscale Funnel is not enabled on this tailnet/device."));
|
||||
runtime.error(
|
||||
info(
|
||||
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
|
||||
@@ -250,10 +228,7 @@ export async function ensureFunnel(
|
||||
"macOS user-space tailscaled docs: https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS",
|
||||
),
|
||||
);
|
||||
const proceed = await prompt(
|
||||
"Attempt local setup with user-space tailscaled?",
|
||||
true,
|
||||
);
|
||||
const proceed = await prompt("Attempt local setup with user-space tailscaled?", true);
|
||||
if (!proceed) runtime.exit(1);
|
||||
await ensureBinary("brew", exec, runtime);
|
||||
await ensureGoInstalled(exec, prompt, runtime);
|
||||
@@ -261,14 +236,10 @@ export async function ensureFunnel(
|
||||
}
|
||||
|
||||
logVerbose(`Enabling funnel on port ${port}…`);
|
||||
const { stdout } = await exec(
|
||||
tailscaleBin,
|
||||
["funnel", "--yes", "--bg", `${port}`],
|
||||
{
|
||||
maxBuffer: 200_000,
|
||||
timeoutMs: 15_000,
|
||||
},
|
||||
);
|
||||
const { stdout } = await exec(tailscaleBin, ["funnel", "--yes", "--bg", `${port}`], {
|
||||
maxBuffer: 200_000,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
if (stdout.trim()) console.log(stdout.trim());
|
||||
} catch (err) {
|
||||
const errOutput = err as { stdout?: unknown; stderr?: unknown };
|
||||
@@ -287,19 +258,14 @@ export async function ensureFunnel(
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
stderr.includes("client version") ||
|
||||
stdout.includes("client version")
|
||||
) {
|
||||
if (stderr.includes("client version") || stdout.includes("client version")) {
|
||||
console.error(
|
||||
warn(
|
||||
"Tailscale client/server version mismatch detected; try updating tailscale/tailscaled.",
|
||||
),
|
||||
);
|
||||
}
|
||||
runtime.error(
|
||||
"Failed to enable Tailscale Funnel. Is it allowed on your tailnet?",
|
||||
);
|
||||
runtime.error("Failed to enable Tailscale Funnel. Is it allowed on your tailnet?");
|
||||
runtime.error(
|
||||
info(
|
||||
"Tip: Funnel is optional for CLAWDBOT. You can keep running the web gateway without it: `pnpm clawdbot gateway`",
|
||||
@@ -319,10 +285,7 @@ export async function ensureFunnel(
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableTailscaleServe(
|
||||
port: number,
|
||||
exec: typeof runExec = runExec,
|
||||
) {
|
||||
export async function enableTailscaleServe(port: number, exec: typeof runExec = runExec) {
|
||||
const tailscaleBin = await getTailscaleBinary();
|
||||
await exec(tailscaleBin, ["serve", "--bg", "--yes", `${port}`], {
|
||||
maxBuffer: 200_000,
|
||||
@@ -338,10 +301,7 @@ export async function disableTailscaleServe(exec: typeof runExec = runExec) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function enableTailscaleFunnel(
|
||||
port: number,
|
||||
exec: typeof runExec = runExec,
|
||||
) {
|
||||
export async function enableTailscaleFunnel(port: number, exec: typeof runExec = runExec) {
|
||||
const tailscaleBin = await getTailscaleBinary();
|
||||
await exec(tailscaleBin, ["funnel", "--bg", "--yes", `${port}`], {
|
||||
maxBuffer: 200_000,
|
||||
|
||||
@@ -4,9 +4,7 @@ type UnhandledRejectionHandler = (reason: unknown) => boolean;
|
||||
|
||||
const handlers = new Set<UnhandledRejectionHandler>();
|
||||
|
||||
export function registerUnhandledRejectionHandler(
|
||||
handler: UnhandledRejectionHandler,
|
||||
): () => void {
|
||||
export function registerUnhandledRejectionHandler(handler: UnhandledRejectionHandler): () => void {
|
||||
handlers.add(handler);
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
|
||||
@@ -66,10 +66,9 @@ async function detectPackageManager(root: string): Promise<PackageManager> {
|
||||
}
|
||||
|
||||
async function detectGitRoot(root: string): Promise<string | null> {
|
||||
const res = await runCommandWithTimeout(
|
||||
["git", "-C", root, "rev-parse", "--show-toplevel"],
|
||||
{ timeoutMs: 4000 },
|
||||
).catch(() => null);
|
||||
const res = await runCommandWithTimeout(["git", "-C", root, "rev-parse", "--show-toplevel"], {
|
||||
timeoutMs: 4000,
|
||||
}).catch(() => null);
|
||||
if (!res || res.code !== 0) return null;
|
||||
const top = res.stdout.trim();
|
||||
return top ? path.resolve(top) : null;
|
||||
@@ -106,21 +105,15 @@ export async function checkGitUpdateStatus(params: {
|
||||
["git", "-C", root, "rev-parse", "--abbrev-ref", "@{upstream}"],
|
||||
{ timeoutMs },
|
||||
).catch(() => null);
|
||||
const upstream =
|
||||
upstreamRes && upstreamRes.code === 0 ? upstreamRes.stdout.trim() : null;
|
||||
const upstream = upstreamRes && upstreamRes.code === 0 ? upstreamRes.stdout.trim() : null;
|
||||
|
||||
const dirtyRes = await runCommandWithTimeout(
|
||||
["git", "-C", root, "status", "--porcelain"],
|
||||
{ timeoutMs },
|
||||
).catch(() => null);
|
||||
const dirty =
|
||||
dirtyRes && dirtyRes.code === 0 ? dirtyRes.stdout.trim().length > 0 : null;
|
||||
const dirtyRes = await runCommandWithTimeout(["git", "-C", root, "status", "--porcelain"], {
|
||||
timeoutMs,
|
||||
}).catch(() => null);
|
||||
const dirty = dirtyRes && dirtyRes.code === 0 ? dirtyRes.stdout.trim().length > 0 : null;
|
||||
|
||||
const fetchOk = params.fetch
|
||||
? await runCommandWithTimeout(
|
||||
["git", "-C", root, "fetch", "--quiet", "--prune"],
|
||||
{ timeoutMs },
|
||||
)
|
||||
? await runCommandWithTimeout(["git", "-C", root, "fetch", "--quiet", "--prune"], { timeoutMs })
|
||||
.then((r) => r.code === 0)
|
||||
.catch(() => false)
|
||||
: null;
|
||||
@@ -128,22 +121,12 @@ export async function checkGitUpdateStatus(params: {
|
||||
const counts =
|
||||
upstream && upstream.length > 0
|
||||
? await runCommandWithTimeout(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
root,
|
||||
"rev-list",
|
||||
"--left-right",
|
||||
"--count",
|
||||
`HEAD...${upstream}`,
|
||||
],
|
||||
["git", "-C", root, "rev-list", "--left-right", "--count", `HEAD...${upstream}`],
|
||||
{ timeoutMs },
|
||||
).catch(() => null)
|
||||
: null;
|
||||
|
||||
const parseCounts = (
|
||||
raw: string,
|
||||
): { ahead: number; behind: number } | null => {
|
||||
const parseCounts = (raw: string): { ahead: number; behind: number } | null => {
|
||||
const parts = raw.trim().split(/\s+/);
|
||||
if (parts.length < 2) return null;
|
||||
const ahead = Number.parseInt(parts[0] ?? "", 10);
|
||||
@@ -151,8 +134,7 @@ export async function checkGitUpdateStatus(params: {
|
||||
if (!Number.isFinite(ahead) || !Number.isFinite(behind)) return null;
|
||||
return { ahead, behind };
|
||||
};
|
||||
const parsed =
|
||||
counts && counts.code === 0 ? parseCounts(counts.stdout) : null;
|
||||
const parsed = counts && counts.code === 0 ? parseCounts(counts.stdout) : null;
|
||||
|
||||
return {
|
||||
root,
|
||||
@@ -268,10 +250,7 @@ export async function checkDepsStatus(params: {
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), Math.max(250, timeoutMs));
|
||||
try {
|
||||
@@ -286,26 +265,19 @@ export async function fetchNpmLatestVersion(params?: {
|
||||
}): Promise<RegistryStatus> {
|
||||
const timeoutMs = params?.timeoutMs ?? 3500;
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
"https://registry.npmjs.org/clawdbot/latest",
|
||||
timeoutMs,
|
||||
);
|
||||
const res = await fetchWithTimeout("https://registry.npmjs.org/clawdbot/latest", timeoutMs);
|
||||
if (!res.ok) {
|
||||
return { latestVersion: null, error: `HTTP ${res.status}` };
|
||||
}
|
||||
const json = (await res.json()) as { version?: unknown };
|
||||
const latestVersion =
|
||||
typeof json?.version === "string" ? json.version : null;
|
||||
const latestVersion = typeof json?.version === "string" ? json.version : null;
|
||||
return { latestVersion };
|
||||
} catch (err) {
|
||||
return { latestVersion: null, error: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export function compareSemverStrings(
|
||||
a: string | null,
|
||||
b: string | null,
|
||||
): number | null {
|
||||
export function compareSemverStrings(a: string | null, b: string | null): number | null {
|
||||
const pa = parseSemver(a);
|
||||
const pb = parseSemver(b);
|
||||
if (!pa || !pb) return null;
|
||||
@@ -328,9 +300,7 @@ export async function checkUpdateStatus(params: {
|
||||
root: null,
|
||||
installKind: "unknown",
|
||||
packageManager: "unknown",
|
||||
registry: params.includeRegistry
|
||||
? await fetchNpmLatestVersion({ timeoutMs })
|
||||
: undefined,
|
||||
registry: params.includeRegistry ? await fetchNpmLatestVersion({ timeoutMs }) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -338,9 +308,7 @@ export async function checkUpdateStatus(params: {
|
||||
const gitRoot = await detectGitRoot(root);
|
||||
const isGit = gitRoot && path.resolve(gitRoot) === root;
|
||||
|
||||
const installKind: UpdateCheckResult["installKind"] = isGit
|
||||
? "git"
|
||||
: "package";
|
||||
const installKind: UpdateCheckResult["installKind"] = isGit ? "git" : "package";
|
||||
const git = isGit
|
||||
? await checkGitUpdateStatus({
|
||||
root,
|
||||
@@ -349,9 +317,7 @@ export async function checkUpdateStatus(params: {
|
||||
})
|
||||
: undefined;
|
||||
const deps = await checkDepsStatus({ root, manager: pm });
|
||||
const registry = params.includeRegistry
|
||||
? await fetchNpmLatestVersion({ timeoutMs })
|
||||
: undefined;
|
||||
const registry = params.includeRegistry ? await fetchNpmLatestVersion({ timeoutMs }) : undefined;
|
||||
|
||||
return {
|
||||
root,
|
||||
|
||||
@@ -68,8 +68,9 @@ describe("runGatewayUpdate", () => {
|
||||
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
|
||||
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
|
||||
[`git -C ${tempDir} status --porcelain`]: { stdout: "" },
|
||||
[`git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`]:
|
||||
{ stdout: "origin/main" },
|
||||
[`git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`]: {
|
||||
stdout: "origin/main",
|
||||
},
|
||||
[`git -C ${tempDir} fetch --all --prune`]: { stdout: "" },
|
||||
[`git -C ${tempDir} rebase @{upstream}`]: { code: 1, stderr: "conflict" },
|
||||
[`git -C ${tempDir} rebase --abort`]: { stdout: "" },
|
||||
@@ -127,8 +128,6 @@ describe("runGatewayUpdate", () => {
|
||||
|
||||
expect(result.status).toBe("error");
|
||||
expect(result.reason).toBe("not-clawdbot-root");
|
||||
expect(calls.some((call) => call.includes("status --porcelain"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(calls.some((call) => call.includes("status --porcelain"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,12 +110,9 @@ async function resolveGitRoot(
|
||||
timeoutMs: number,
|
||||
): Promise<string | null> {
|
||||
for (const dir of candidates) {
|
||||
const res = await runCommand(
|
||||
["git", "-C", dir, "rev-parse", "--show-toplevel"],
|
||||
{
|
||||
timeoutMs,
|
||||
},
|
||||
);
|
||||
const res = await runCommand(["git", "-C", dir, "rev-parse", "--show-toplevel"], {
|
||||
timeoutMs,
|
||||
});
|
||||
if (res.code === 0) {
|
||||
const root = res.stdout.trim();
|
||||
if (root) return root;
|
||||
@@ -174,17 +171,7 @@ type RunStepOptions = {
|
||||
};
|
||||
|
||||
async function runStep(opts: RunStepOptions): Promise<UpdateStepResult> {
|
||||
const {
|
||||
runCommand,
|
||||
name,
|
||||
argv,
|
||||
cwd,
|
||||
timeoutMs,
|
||||
env,
|
||||
progress,
|
||||
stepIndex,
|
||||
totalSteps,
|
||||
} = opts;
|
||||
const { runCommand, name, argv, cwd, timeoutMs, env, progress, stepIndex, totalSteps } = opts;
|
||||
const command = argv.join(" ");
|
||||
|
||||
const stepInfo: UpdateStepInfo = {
|
||||
@@ -220,11 +207,7 @@ async function runStep(opts: RunStepOptions): Promise<UpdateStepResult> {
|
||||
};
|
||||
}
|
||||
|
||||
function managerScriptArgs(
|
||||
manager: "pnpm" | "bun" | "npm",
|
||||
script: string,
|
||||
args: string[] = [],
|
||||
) {
|
||||
function managerScriptArgs(manager: "pnpm" | "bun" | "npm", script: string, args: string[] = []) {
|
||||
if (manager === "pnpm") return ["pnpm", script, ...args];
|
||||
if (manager === "bun") return ["bun", "run", script, ...args];
|
||||
if (args.length > 0) return ["npm", "run", script, "--", ...args];
|
||||
@@ -240,9 +223,7 @@ function managerInstallArgs(manager: "pnpm" | "bun" | "npm") {
|
||||
// Total number of visible steps in a successful git update flow
|
||||
const GIT_UPDATE_TOTAL_STEPS = 9;
|
||||
|
||||
export async function runGatewayUpdate(
|
||||
opts: UpdateRunnerOptions = {},
|
||||
): Promise<UpdateRunResult> {
|
||||
export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<UpdateRunResult> {
|
||||
const startedAt = Date.now();
|
||||
const runCommand =
|
||||
opts.runCommand ??
|
||||
@@ -298,19 +279,15 @@ export async function runGatewayUpdate(
|
||||
|
||||
if (gitRoot && pkgRoot && path.resolve(gitRoot) === path.resolve(pkgRoot)) {
|
||||
// Get current SHA (not a visible step, no progress)
|
||||
const beforeShaResult = await runCommand(
|
||||
["git", "-C", gitRoot, "rev-parse", "HEAD"],
|
||||
{ cwd: gitRoot, timeoutMs },
|
||||
);
|
||||
const beforeShaResult = await runCommand(["git", "-C", gitRoot, "rev-parse", "HEAD"], {
|
||||
cwd: gitRoot,
|
||||
timeoutMs,
|
||||
});
|
||||
const beforeSha = beforeShaResult.stdout.trim() || null;
|
||||
const beforeVersion = await readPackageVersion(gitRoot);
|
||||
|
||||
const statusCheck = await runStep(
|
||||
step(
|
||||
"clean check",
|
||||
["git", "-C", gitRoot, "status", "--porcelain"],
|
||||
gitRoot,
|
||||
),
|
||||
step("clean check", ["git", "-C", gitRoot, "status", "--porcelain"], gitRoot),
|
||||
);
|
||||
steps.push(statusCheck);
|
||||
const hasUncommittedChanges =
|
||||
@@ -330,15 +307,7 @@ export async function runGatewayUpdate(
|
||||
const upstreamStep = await runStep(
|
||||
step(
|
||||
"upstream check",
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
gitRoot,
|
||||
"rev-parse",
|
||||
"--abbrev-ref",
|
||||
"--symbolic-full-name",
|
||||
"@{upstream}",
|
||||
],
|
||||
["git", "-C", gitRoot, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
|
||||
gitRoot,
|
||||
),
|
||||
);
|
||||
@@ -356,27 +325,19 @@ export async function runGatewayUpdate(
|
||||
}
|
||||
|
||||
const fetchStep = await runStep(
|
||||
step(
|
||||
"git fetch",
|
||||
["git", "-C", gitRoot, "fetch", "--all", "--prune"],
|
||||
gitRoot,
|
||||
),
|
||||
step("git fetch", ["git", "-C", gitRoot, "fetch", "--all", "--prune"], gitRoot),
|
||||
);
|
||||
steps.push(fetchStep);
|
||||
|
||||
const rebaseStep = await runStep(
|
||||
step(
|
||||
"git rebase",
|
||||
["git", "-C", gitRoot, "rebase", "@{upstream}"],
|
||||
gitRoot,
|
||||
),
|
||||
step("git rebase", ["git", "-C", gitRoot, "rebase", "@{upstream}"], gitRoot),
|
||||
);
|
||||
steps.push(rebaseStep);
|
||||
if (rebaseStep.exitCode !== 0) {
|
||||
const abortResult = await runCommand(
|
||||
["git", "-C", gitRoot, "rebase", "--abort"],
|
||||
{ cwd: gitRoot, timeoutMs },
|
||||
);
|
||||
const abortResult = await runCommand(["git", "-C", gitRoot, "rebase", "--abort"], {
|
||||
cwd: gitRoot,
|
||||
timeoutMs,
|
||||
});
|
||||
steps.push({
|
||||
name: "git rebase --abort",
|
||||
command: "git rebase --abort",
|
||||
@@ -399,14 +360,10 @@ export async function runGatewayUpdate(
|
||||
|
||||
const manager = await detectPackageManager(gitRoot);
|
||||
|
||||
const depsStep = await runStep(
|
||||
step("deps install", managerInstallArgs(manager), gitRoot),
|
||||
);
|
||||
const depsStep = await runStep(step("deps install", managerInstallArgs(manager), gitRoot));
|
||||
steps.push(depsStep);
|
||||
|
||||
const buildStep = await runStep(
|
||||
step("build", managerScriptArgs(manager, "build"), gitRoot),
|
||||
);
|
||||
const buildStep = await runStep(step("build", managerScriptArgs(manager, "build"), gitRoot));
|
||||
steps.push(buildStep);
|
||||
|
||||
const uiBuildStep = await runStep(
|
||||
@@ -426,11 +383,7 @@ export async function runGatewayUpdate(
|
||||
|
||||
const failedStep = steps.find((s) => s.exitCode !== 0);
|
||||
const afterShaStep = await runStep(
|
||||
step(
|
||||
"git rev-parse HEAD (after)",
|
||||
["git", "-C", gitRoot, "rev-parse", "HEAD"],
|
||||
gitRoot,
|
||||
),
|
||||
step("git rev-parse HEAD (after)", ["git", "-C", gitRoot, "rev-parse", "HEAD"], gitRoot),
|
||||
);
|
||||
steps.push(afterShaStep);
|
||||
const afterVersion = await readPackageVersion(gitRoot);
|
||||
|
||||
@@ -12,22 +12,15 @@ import {
|
||||
|
||||
describe("voicewake store", () => {
|
||||
it("returns defaults when missing", async () => {
|
||||
const baseDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-voicewake-"),
|
||||
);
|
||||
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-voicewake-"));
|
||||
const cfg = await loadVoiceWakeConfig(baseDir);
|
||||
expect(cfg.triggers).toEqual(defaultVoiceWakeTriggers());
|
||||
expect(cfg.updatedAtMs).toBe(0);
|
||||
});
|
||||
|
||||
it("sanitizes and persists triggers", async () => {
|
||||
const baseDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-voicewake-"),
|
||||
);
|
||||
const saved = await setVoiceWakeTriggers(
|
||||
[" hi ", "", " there "],
|
||||
baseDir,
|
||||
);
|
||||
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-voicewake-"));
|
||||
const saved = await setVoiceWakeTriggers([" hi ", "", " there "], baseDir);
|
||||
expect(saved.triggers).toEqual(["hi", "there"]);
|
||||
expect(saved.updatedAtMs).toBeGreaterThan(0);
|
||||
|
||||
@@ -37,9 +30,7 @@ describe("voicewake store", () => {
|
||||
});
|
||||
|
||||
it("falls back to defaults when triggers empty", async () => {
|
||||
const baseDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-voicewake-"),
|
||||
);
|
||||
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-voicewake-"));
|
||||
const saved = await setVoiceWakeTriggers(["", " "], baseDir);
|
||||
expect(saved.triggers).toEqual(defaultVoiceWakeTriggers());
|
||||
});
|
||||
|
||||
@@ -58,9 +58,7 @@ export function defaultVoiceWakeTriggers() {
|
||||
return [...DEFAULT_TRIGGERS];
|
||||
}
|
||||
|
||||
export async function loadVoiceWakeConfig(
|
||||
baseDir?: string,
|
||||
): Promise<VoiceWakeConfig> {
|
||||
export async function loadVoiceWakeConfig(baseDir?: string): Promise<VoiceWakeConfig> {
|
||||
const filePath = resolvePath(baseDir);
|
||||
const existing = await readJSON<VoiceWakeConfig>(filePath);
|
||||
if (!existing) {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
renderWideAreaBridgeZoneText,
|
||||
WIDE_AREA_DISCOVERY_DOMAIN,
|
||||
} from "./widearea-dns.js";
|
||||
import { renderWideAreaBridgeZoneText, WIDE_AREA_DISCOVERY_DOMAIN } from "./widearea-dns.js";
|
||||
|
||||
describe("wide-area DNS-SD zone rendering", () => {
|
||||
it("renders a clawdbot.internal zone with bridge PTR/SRV/TXT records", () => {
|
||||
@@ -23,12 +20,8 @@ describe("wide-area DNS-SD zone rendering", () => {
|
||||
expect(txt).toContain(`$ORIGIN ${WIDE_AREA_DISCOVERY_DOMAIN}`);
|
||||
expect(txt).toContain(`studio-london IN A 100.123.224.76`);
|
||||
expect(txt).toContain(`studio-london IN AAAA fd7a:115c:a1e0::8801:e04c`);
|
||||
expect(txt).toContain(
|
||||
`_clawdbot-bridge._tcp IN PTR studio-london._clawdbot-bridge._tcp`,
|
||||
);
|
||||
expect(txt).toContain(
|
||||
`studio-london._clawdbot-bridge._tcp IN SRV 0 0 18790 studio-london`,
|
||||
);
|
||||
expect(txt).toContain(`_clawdbot-bridge._tcp IN PTR studio-london._clawdbot-bridge._tcp`);
|
||||
expect(txt).toContain(`studio-london._clawdbot-bridge._tcp IN SRV 0 0 18790 studio-london`);
|
||||
expect(txt).toContain(`displayName=Mac Studio (Clawdbot)`);
|
||||
expect(txt).toContain(`gatewayPort=18789`);
|
||||
expect(txt).toContain(`sshPort=22`);
|
||||
|
||||
@@ -23,10 +23,7 @@ function dnsLabel(raw: string, fallback: string): string {
|
||||
}
|
||||
|
||||
function txtQuote(value: string): string {
|
||||
const escaped = value
|
||||
.replaceAll("\\", "\\\\")
|
||||
.replaceAll('"', '\\"')
|
||||
.replaceAll("\n", "\\n");
|
||||
const escaped = value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n");
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
|
||||
@@ -84,10 +81,7 @@ export type WideAreaBridgeZoneOpts = {
|
||||
function renderZone(opts: WideAreaBridgeZoneOpts & { serial: number }): string {
|
||||
const hostname = os.hostname().split(".")[0] ?? "clawdbot";
|
||||
const hostLabel = dnsLabel(opts.hostLabel ?? hostname, "clawdbot");
|
||||
const instanceLabel = dnsLabel(
|
||||
opts.instanceLabel ?? `${hostname}-bridge`,
|
||||
"clawdbot-bridge",
|
||||
);
|
||||
const instanceLabel = dnsLabel(opts.instanceLabel ?? `${hostname}-bridge`, "clawdbot-bridge");
|
||||
|
||||
const txt = [
|
||||
`displayName=${opts.displayName.trim() || hostname}`,
|
||||
@@ -120,22 +114,14 @@ function renderZone(opts: WideAreaBridgeZoneOpts & { serial: number }): string {
|
||||
records.push(`${hostLabel} IN AAAA ${opts.tailnetIPv6}`);
|
||||
}
|
||||
|
||||
records.push(
|
||||
`_clawdbot-bridge._tcp IN PTR ${instanceLabel}._clawdbot-bridge._tcp`,
|
||||
);
|
||||
records.push(
|
||||
`${instanceLabel}._clawdbot-bridge._tcp IN SRV 0 0 ${opts.bridgePort} ${hostLabel}`,
|
||||
);
|
||||
records.push(
|
||||
`${instanceLabel}._clawdbot-bridge._tcp IN TXT ${txt.map(txtQuote).join(" ")}`,
|
||||
);
|
||||
records.push(`_clawdbot-bridge._tcp IN PTR ${instanceLabel}._clawdbot-bridge._tcp`);
|
||||
records.push(`${instanceLabel}._clawdbot-bridge._tcp IN SRV 0 0 ${opts.bridgePort} ${hostLabel}`);
|
||||
records.push(`${instanceLabel}._clawdbot-bridge._tcp IN TXT ${txt.map(txtQuote).join(" ")}`);
|
||||
|
||||
const contentBody = `${records.join("\n")}\n`;
|
||||
const hashBody = `${records
|
||||
.map((line) =>
|
||||
line === soaLine
|
||||
? `@ IN SOA ns1 hostmaster SERIAL 7200 3600 1209600 60`
|
||||
: line,
|
||||
line === soaLine ? `@ IN SOA ns1 hostmaster SERIAL 7200 3600 1209600 60` : line,
|
||||
)
|
||||
.join("\n")}\n`;
|
||||
const contentHash = computeContentHash(hashBody);
|
||||
|
||||
Reference in New Issue
Block a user