Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2065e8d96 | ||
|
|
78d9f5baff |
No files matched your search
@@ -7,7 +7,6 @@
|
|||||||
- Tests: colocated `*.test.ts`.
|
- Tests: colocated `*.test.ts`.
|
||||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||||
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
||||||
- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `clawdbot` in `devDependencies` or `peerDependencies` instead (runtime resolves `clawdbot/plugin-sdk` via jiti alias).
|
|
||||||
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
||||||
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
|
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
|
||||||
- Core channel docs: `docs/channels/`
|
- Core channel docs: `docs/channels/`
|
||||||
|
|||||||
+1
-4
@@ -8,21 +8,18 @@ Docs: https://docs.clawd.bot
|
|||||||
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
||||||
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
|
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
|
||||||
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
||||||
- Docs: remove the misplaced Google Docs Editor entry from the showcase. (#1547) Thanks @aj47.
|
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
|
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
|
||||||
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
|
|
||||||
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
|
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
|
||||||
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
|
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
|
||||||
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
|
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
|
||||||
- TUI: include Gateway slash commands in autocomplete and `/help`.
|
- TUI: include Gateway slash commands in autocomplete and `/help`.
|
||||||
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
|
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
|
||||||
- CLI: suppress diagnostic session/run noise during auth probes.
|
- CLI: suppress diagnostic session/run noise during auth probes.
|
||||||
- CLI: hide auth probe timeout warnings from embedded runs.
|
|
||||||
- CLI: render auth probe results as a table in `clawdbot models status`.
|
|
||||||
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
|
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
|
||||||
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
|
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
|
||||||
|
- TUI/Gateway: reset session token counters on `/new` and refresh the footer immediately. (#1542) Thanks @robbyczgw-cla.
|
||||||
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
|
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
|
||||||
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
||||||
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
|
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
|
||||||
|
|||||||
@@ -327,10 +327,16 @@ Full setup walkthrough (28m) by VelvetShark.
|
|||||||
|
|
||||||
<Card title="OpenRouter Transcription" icon="microphone" href="https://clawdhub.com/obviyus/openrouter-transcribe">
|
<Card title="OpenRouter Transcription" icon="microphone" href="https://clawdhub.com/obviyus/openrouter-transcribe">
|
||||||
**@obviyus** • `transcription` `multilingual` `skill`
|
**@obviyus** • `transcription` `multilingual` `skill`
|
||||||
|
|
||||||
Multi-lingual audio transcription via OpenRouter (Gemini, etc). Available on ClawdHub.
|
Multi-lingual audio transcription via OpenRouter (Gemini, etc). Available on ClawdHub.
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Google Docs Editor" icon="file-word">
|
||||||
|
**Community** • `docs` `editing` `skill`
|
||||||
|
|
||||||
|
Rich-text Google Docs editing skill. Built rapidly with Claude Code.
|
||||||
|
</Card>
|
||||||
|
|
||||||
</CardGroup>
|
</CardGroup>
|
||||||
|
|
||||||
## 🏗️ Infrastructure & Deployment
|
## 🏗️ Infrastructure & Deployment
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ export async function runEmbeddedPiAgent(
|
|||||||
? "markdown"
|
? "markdown"
|
||||||
: "plain"
|
: "plain"
|
||||||
: "markdown");
|
: "markdown");
|
||||||
const isProbeSession = params.sessionId?.startsWith("probe-") ?? false;
|
|
||||||
|
|
||||||
return enqueueCommandInLane(sessionLane, () =>
|
return enqueueCommandInLane(sessionLane, () =>
|
||||||
enqueueGlobal(async () => {
|
enqueueGlobal(async () => {
|
||||||
@@ -456,7 +455,7 @@ export async function runEmbeddedPiAgent(
|
|||||||
cfg: params.config,
|
cfg: params.config,
|
||||||
agentDir: params.agentDir,
|
agentDir: params.agentDir,
|
||||||
});
|
});
|
||||||
if (timedOut && !isProbeSession) {
|
if (timedOut) {
|
||||||
log.warn(
|
log.warn(
|
||||||
`Profile ${lastProfileId} timed out (possible rate limit). Trying next account...`,
|
`Profile ${lastProfileId} timed out (possible rate limit). Trying next account...`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -595,23 +595,18 @@ export async function runEmbeddedAttempt(
|
|||||||
setActiveEmbeddedRun(params.sessionId, queueHandle);
|
setActiveEmbeddedRun(params.sessionId, queueHandle);
|
||||||
|
|
||||||
let abortWarnTimer: NodeJS.Timeout | undefined;
|
let abortWarnTimer: NodeJS.Timeout | undefined;
|
||||||
const isProbeSession = params.sessionId?.startsWith("probe-") ?? false;
|
|
||||||
const abortTimer = setTimeout(
|
const abortTimer = setTimeout(
|
||||||
() => {
|
() => {
|
||||||
if (!isProbeSession) {
|
log.warn(
|
||||||
log.warn(
|
`embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`,
|
||||||
`embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
abortRun(true);
|
abortRun(true);
|
||||||
if (!abortWarnTimer) {
|
if (!abortWarnTimer) {
|
||||||
abortWarnTimer = setTimeout(() => {
|
abortWarnTimer = setTimeout(() => {
|
||||||
if (!activeSession.isStreaming) return;
|
if (!activeSession.isStreaming) return;
|
||||||
if (!isProbeSession) {
|
log.warn(
|
||||||
log.warn(
|
`embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`,
|
||||||
`embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
} from "../../infra/provider-usage.js";
|
} from "../../infra/provider-usage.js";
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
import { colorize, theme } from "../../terminal/theme.js";
|
import { colorize, theme } from "../../terminal/theme.js";
|
||||||
import { renderTable } from "../../terminal/table.js";
|
|
||||||
import { formatCliCommand } from "../../cli/command-format.js";
|
import { formatCliCommand } from "../../cli/command-format.js";
|
||||||
import { shortenHomePath } from "../../utils.js";
|
import { shortenHomePath } from "../../utils.js";
|
||||||
import { resolveProviderAuthOverview } from "./list.auth-overview.js";
|
import { resolveProviderAuthOverview } from "./list.auth-overview.js";
|
||||||
@@ -36,6 +35,7 @@ import { isRich } from "./list.format.js";
|
|||||||
import {
|
import {
|
||||||
describeProbeSummary,
|
describeProbeSummary,
|
||||||
formatProbeLatency,
|
formatProbeLatency,
|
||||||
|
groupProbeResults,
|
||||||
runAuthProbes,
|
runAuthProbes,
|
||||||
sortProbeResults,
|
sortProbeResults,
|
||||||
type AuthProbeSummary,
|
type AuthProbeSummary,
|
||||||
@@ -571,8 +571,7 @@ export async function modelsStatusCommand(
|
|||||||
if (probeSummary.results.length === 0) {
|
if (probeSummary.results.length === 0) {
|
||||||
runtime.log(colorize(rich, theme.muted, "- none"));
|
runtime.log(colorize(rich, theme.muted, "- none"));
|
||||||
} else {
|
} else {
|
||||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
const grouped = groupProbeResults(sortProbeResults(probeSummary.results));
|
||||||
const sorted = sortProbeResults(probeSummary.results);
|
|
||||||
const statusColor = (status: string) => {
|
const statusColor = (status: string) => {
|
||||||
if (status === "ok") return theme.success;
|
if (status === "ok") return theme.success;
|
||||||
if (status === "rate_limit") return theme.warn;
|
if (status === "rate_limit") return theme.warn;
|
||||||
@@ -581,33 +580,29 @@ export async function modelsStatusCommand(
|
|||||||
if (status === "no_model") return theme.muted;
|
if (status === "no_model") return theme.muted;
|
||||||
return theme.muted;
|
return theme.muted;
|
||||||
};
|
};
|
||||||
const rows = sorted.map((result) => {
|
for (const [provider, results] of grouped) {
|
||||||
const status = colorize(rich, statusColor(result.status), result.status);
|
const modelLabel = results.find((r) => r.model)?.model ?? "-";
|
||||||
const latency = formatProbeLatency(result.latencyMs);
|
runtime.log(
|
||||||
const detail = result.error ? colorize(rich, theme.muted, result.error) : "";
|
`- ${theme.heading(provider)}${colorize(
|
||||||
const modelLabel = result.model ?? `${result.provider}/-`;
|
rich,
|
||||||
const modeLabel = result.mode ? ` ${colorize(rich, theme.muted, `(${result.mode})`)}` : "";
|
theme.muted,
|
||||||
const profile = `${colorize(rich, theme.accent, result.label)}${modeLabel}`;
|
modelLabel ? ` (model: ${modelLabel})` : "",
|
||||||
const statusLabel = `${status}${colorize(rich, theme.muted, ` · ${latency}`)}`;
|
)}`,
|
||||||
return {
|
);
|
||||||
Model: colorize(rich, theme.heading, modelLabel),
|
for (const result of results) {
|
||||||
Profile: profile,
|
const status = colorize(rich, statusColor(result.status), result.status);
|
||||||
Status: statusLabel,
|
const latency = formatProbeLatency(result.latencyMs);
|
||||||
Detail: detail,
|
const mode = result.mode ? ` (${result.mode})` : "";
|
||||||
};
|
const detail = result.error ? colorize(rich, theme.muted, ` - ${result.error}`) : "";
|
||||||
});
|
runtime.log(
|
||||||
runtime.log(
|
` - ${colorize(rich, theme.accent, result.label)}${mode} ${status} ${colorize(
|
||||||
renderTable({
|
rich,
|
||||||
width: tableWidth,
|
theme.muted,
|
||||||
columns: [
|
latency,
|
||||||
{ key: "Model", header: "Model", minWidth: 18 },
|
)}${detail}`,
|
||||||
{ key: "Profile", header: "Profile", minWidth: 24 },
|
);
|
||||||
{ key: "Status", header: "Status", minWidth: 12 },
|
}
|
||||||
{ key: "Detail", header: "Detail", minWidth: 16, flex: true },
|
}
|
||||||
],
|
|
||||||
rows,
|
|
||||||
}).trimEnd(),
|
|
||||||
);
|
|
||||||
runtime.log(colorize(rich, theme.muted, describeProbeSummary(probeSummary)));
|
runtime.log(colorize(rich, theme.muted, describeProbeSummary(probeSummary)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -345,11 +345,19 @@ describe("gateway server sessions", () => {
|
|||||||
const reset = await rpcReq<{
|
const reset = await rpcReq<{
|
||||||
ok: true;
|
ok: true;
|
||||||
key: string;
|
key: string;
|
||||||
entry: { sessionId: string };
|
entry: {
|
||||||
|
sessionId: string;
|
||||||
|
inputTokens?: number;
|
||||||
|
outputTokens?: number;
|
||||||
|
totalTokens?: number;
|
||||||
|
};
|
||||||
}>(ws, "sessions.reset", { key: "agent:main:main" });
|
}>(ws, "sessions.reset", { key: "agent:main:main" });
|
||||||
expect(reset.ok).toBe(true);
|
expect(reset.ok).toBe(true);
|
||||||
expect(reset.payload?.key).toBe("agent:main:main");
|
expect(reset.payload?.key).toBe("agent:main:main");
|
||||||
expect(reset.payload?.entry.sessionId).not.toBe("sess-main");
|
expect(reset.payload?.entry.sessionId).not.toBe("sess-main");
|
||||||
|
expect(reset.payload?.entry.inputTokens).toBe(0);
|
||||||
|
expect(reset.payload?.entry.outputTokens).toBe(0);
|
||||||
|
expect(reset.payload?.entry.totalTokens).toBe(0);
|
||||||
|
|
||||||
const badThinking = await rpcReq(ws, "sessions.patch", {
|
const badThinking = await rpcReq(ws, "sessions.patch", {
|
||||||
key: "agent:main:main",
|
key: "agent:main:main",
|
||||||
|
|||||||
+2
-150
@@ -1,21 +1,8 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import * as tailscale from "./tailscale.js";
|
import { ensureGoInstalled, ensureTailscaledInstalled, getTailnetHostname } from "./tailscale.js";
|
||||||
|
|
||||||
const {
|
|
||||||
ensureGoInstalled,
|
|
||||||
ensureTailscaledInstalled,
|
|
||||||
getTailnetHostname,
|
|
||||||
enableTailscaleServe,
|
|
||||||
disableTailscaleServe,
|
|
||||||
ensureFunnel,
|
|
||||||
} = tailscale;
|
|
||||||
|
|
||||||
describe("tailscale helpers", () => {
|
describe("tailscale helpers", () => {
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses DNS name from tailscale status", async () => {
|
it("parses DNS name from tailscale status", async () => {
|
||||||
const exec = vi.fn().mockResolvedValue({
|
const exec = vi.fn().mockResolvedValue({
|
||||||
stdout: JSON.stringify({
|
stdout: JSON.stringify({
|
||||||
@@ -61,139 +48,4 @@ describe("tailscale helpers", () => {
|
|||||||
await ensureTailscaledInstalled(exec as never, prompt, runtime);
|
await ensureTailscaledInstalled(exec as never, prompt, runtime);
|
||||||
expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]);
|
expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enableTailscaleServe attempts normal first, then sudo", async () => {
|
|
||||||
// 1. First attempt fails
|
|
||||||
// 2. Second attempt (sudo) succeeds
|
|
||||||
const exec = vi
|
|
||||||
.fn()
|
|
||||||
.mockRejectedValueOnce(new Error("permission denied"))
|
|
||||||
.mockResolvedValueOnce({ stdout: "" });
|
|
||||||
|
|
||||||
await enableTailscaleServe(3000, exec as never);
|
|
||||||
|
|
||||||
expect(exec).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
expect.stringMatching(/tailscale$/),
|
|
||||||
expect.arrayContaining(["serve", "--bg", "--yes", "3000"]),
|
|
||||||
expect.any(Object),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(exec).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"sudo",
|
|
||||||
expect.arrayContaining([
|
|
||||||
"-n",
|
|
||||||
expect.stringMatching(/tailscale$/),
|
|
||||||
"serve",
|
|
||||||
"--bg",
|
|
||||||
"--yes",
|
|
||||||
"3000",
|
|
||||||
]),
|
|
||||||
expect.any(Object),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("enableTailscaleServe does NOT use sudo if first attempt succeeds", async () => {
|
|
||||||
const exec = vi.fn().mockResolvedValue({ stdout: "" });
|
|
||||||
|
|
||||||
await enableTailscaleServe(3000, exec as never);
|
|
||||||
|
|
||||||
expect(exec).toHaveBeenCalledTimes(1);
|
|
||||||
expect(exec).toHaveBeenCalledWith(
|
|
||||||
expect.stringMatching(/tailscale$/),
|
|
||||||
expect.arrayContaining(["serve", "--bg", "--yes", "3000"]),
|
|
||||||
expect.any(Object),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("disableTailscaleServe uses fallback", async () => {
|
|
||||||
const exec = vi
|
|
||||||
.fn()
|
|
||||||
.mockRejectedValueOnce(new Error("permission denied"))
|
|
||||||
.mockResolvedValueOnce({ stdout: "" });
|
|
||||||
|
|
||||||
await disableTailscaleServe(exec as never);
|
|
||||||
|
|
||||||
expect(exec).toHaveBeenCalledTimes(2);
|
|
||||||
expect(exec).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"sudo",
|
|
||||||
expect.arrayContaining(["-n", expect.stringMatching(/tailscale$/), "serve", "reset"]),
|
|
||||||
expect.any(Object),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ensureFunnel uses fallback for enabling", async () => {
|
|
||||||
// Mock exec:
|
|
||||||
// 1. status (success)
|
|
||||||
// 2. enable (fails)
|
|
||||||
// 3. enable sudo (success)
|
|
||||||
const exec = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ BackendState: "Running" }) }) // status
|
|
||||||
.mockRejectedValueOnce(new Error("permission denied")) // enable normal
|
|
||||||
.mockResolvedValueOnce({ stdout: "" }); // enable sudo
|
|
||||||
|
|
||||||
const runtime = {
|
|
||||||
error: vi.fn(),
|
|
||||||
log: vi.fn(),
|
|
||||||
exit: vi.fn() as unknown as (code: number) => never,
|
|
||||||
};
|
|
||||||
const prompt = vi.fn();
|
|
||||||
|
|
||||||
await ensureFunnel(8080, exec as never, runtime, prompt);
|
|
||||||
|
|
||||||
// 1. status
|
|
||||||
expect(exec).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
expect.stringMatching(/tailscale$/),
|
|
||||||
expect.arrayContaining(["funnel", "status", "--json"]),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. enable normal
|
|
||||||
expect(exec).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
expect.stringMatching(/tailscale$/),
|
|
||||||
expect.arrayContaining(["funnel", "--yes", "--bg", "8080"]),
|
|
||||||
expect.any(Object),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. enable sudo
|
|
||||||
expect(exec).toHaveBeenNthCalledWith(
|
|
||||||
3,
|
|
||||||
"sudo",
|
|
||||||
expect.arrayContaining([
|
|
||||||
"-n",
|
|
||||||
expect.stringMatching(/tailscale$/),
|
|
||||||
"funnel",
|
|
||||||
"--yes",
|
|
||||||
"--bg",
|
|
||||||
"8080",
|
|
||||||
]),
|
|
||||||
expect.any(Object),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("enableTailscaleServe skips sudo on non-permission errors", async () => {
|
|
||||||
const exec = vi.fn().mockRejectedValueOnce(new Error("boom"));
|
|
||||||
|
|
||||||
await expect(enableTailscaleServe(3000, exec as never)).rejects.toThrow("boom");
|
|
||||||
|
|
||||||
expect(exec).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("enableTailscaleServe rethrows original error if sudo fails", async () => {
|
|
||||||
const originalError = Object.assign(new Error("permission denied"), {
|
|
||||||
stderr: "permission denied",
|
|
||||||
});
|
|
||||||
const exec = vi
|
|
||||||
.fn()
|
|
||||||
.mockRejectedValueOnce(originalError)
|
|
||||||
.mockRejectedValueOnce(new Error("sudo: a password is required"));
|
|
||||||
|
|
||||||
await expect(enableTailscaleServe(3000, exec as never)).rejects.toBe(originalError);
|
|
||||||
|
|
||||||
expect(exec).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
+8
-72
@@ -206,64 +206,6 @@ export async function ensureTailscaledInstalled(
|
|||||||
await exec("brew", ["install", "tailscale"]);
|
await exec("brew", ["install", "tailscale"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExecErrorDetails = {
|
|
||||||
stdout?: unknown;
|
|
||||||
stderr?: unknown;
|
|
||||||
message?: unknown;
|
|
||||||
code?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
function extractExecErrorText(err: unknown) {
|
|
||||||
const errOutput = err as ExecErrorDetails;
|
|
||||||
const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : "";
|
|
||||||
const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : "";
|
|
||||||
const message = typeof errOutput.message === "string" ? errOutput.message : "";
|
|
||||||
const code = typeof errOutput.code === "string" ? errOutput.code : "";
|
|
||||||
return { stdout, stderr, message, code };
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPermissionDeniedError(err: unknown): boolean {
|
|
||||||
const { stdout, stderr, message, code } = extractExecErrorText(err);
|
|
||||||
if (code.toUpperCase() === "EACCES") return true;
|
|
||||||
const combined = `${stdout}\n${stderr}\n${message}`.toLowerCase();
|
|
||||||
return (
|
|
||||||
combined.includes("permission denied") ||
|
|
||||||
combined.includes("access denied") ||
|
|
||||||
combined.includes("operation not permitted") ||
|
|
||||||
combined.includes("not permitted") ||
|
|
||||||
combined.includes("requires root") ||
|
|
||||||
combined.includes("must be run as root") ||
|
|
||||||
combined.includes("must be run with sudo") ||
|
|
||||||
combined.includes("requires sudo") ||
|
|
||||||
combined.includes("need sudo")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to attempt a command, and retry with sudo if it fails.
|
|
||||||
async function execWithSudoFallback(
|
|
||||||
exec: typeof runExec,
|
|
||||||
bin: string,
|
|
||||||
args: string[],
|
|
||||||
opts: { maxBuffer?: number; timeoutMs?: number },
|
|
||||||
): Promise<{ stdout: string; stderr: string }> {
|
|
||||||
try {
|
|
||||||
return await exec(bin, args, opts);
|
|
||||||
} catch (err) {
|
|
||||||
if (!isPermissionDeniedError(err)) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
logVerbose(`Command failed, retrying with sudo: ${bin} ${args.join(" ")}`);
|
|
||||||
try {
|
|
||||||
return await exec("sudo", ["-n", bin, ...args], opts);
|
|
||||||
} catch (sudoErr) {
|
|
||||||
const { stderr, message } = extractExecErrorText(sudoErr);
|
|
||||||
const detail = (stderr || message).trim();
|
|
||||||
if (detail) logVerbose(`Sudo retry failed: ${detail}`);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureFunnel(
|
export async function ensureFunnel(
|
||||||
port: number,
|
port: number,
|
||||||
exec: typeof runExec = runExec,
|
exec: typeof runExec = runExec,
|
||||||
@@ -295,16 +237,10 @@ export async function ensureFunnel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
logVerbose(`Enabling funnel on port ${port}…`);
|
logVerbose(`Enabling funnel on port ${port}…`);
|
||||||
// Attempt with fallback
|
const { stdout } = await exec(tailscaleBin, ["funnel", "--yes", "--bg", `${port}`], {
|
||||||
const { stdout } = await execWithSudoFallback(
|
maxBuffer: 200_000,
|
||||||
exec,
|
timeoutMs: 15_000,
|
||||||
tailscaleBin,
|
});
|
||||||
["funnel", "--yes", "--bg", `${port}`],
|
|
||||||
{
|
|
||||||
maxBuffer: 200_000,
|
|
||||||
timeoutMs: 15_000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (stdout.trim()) console.log(stdout.trim());
|
if (stdout.trim()) console.log(stdout.trim());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errOutput = err as { stdout?: unknown; stderr?: unknown };
|
const errOutput = err as { stdout?: unknown; stderr?: unknown };
|
||||||
@@ -352,7 +288,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();
|
const tailscaleBin = await getTailscaleBinary();
|
||||||
await execWithSudoFallback(exec, tailscaleBin, ["serve", "--bg", "--yes", `${port}`], {
|
await exec(tailscaleBin, ["serve", "--bg", "--yes", `${port}`], {
|
||||||
maxBuffer: 200_000,
|
maxBuffer: 200_000,
|
||||||
timeoutMs: 15_000,
|
timeoutMs: 15_000,
|
||||||
});
|
});
|
||||||
@@ -360,7 +296,7 @@ export async function enableTailscaleServe(port: number, exec: typeof runExec =
|
|||||||
|
|
||||||
export async function disableTailscaleServe(exec: typeof runExec = runExec) {
|
export async function disableTailscaleServe(exec: typeof runExec = runExec) {
|
||||||
const tailscaleBin = await getTailscaleBinary();
|
const tailscaleBin = await getTailscaleBinary();
|
||||||
await execWithSudoFallback(exec, tailscaleBin, ["serve", "reset"], {
|
await exec(tailscaleBin, ["serve", "reset"], {
|
||||||
maxBuffer: 200_000,
|
maxBuffer: 200_000,
|
||||||
timeoutMs: 15_000,
|
timeoutMs: 15_000,
|
||||||
});
|
});
|
||||||
@@ -368,7 +304,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();
|
const tailscaleBin = await getTailscaleBinary();
|
||||||
await execWithSudoFallback(exec, tailscaleBin, ["funnel", "--bg", "--yes", `${port}`], {
|
await exec(tailscaleBin, ["funnel", "--bg", "--yes", `${port}`], {
|
||||||
maxBuffer: 200_000,
|
maxBuffer: 200_000,
|
||||||
timeoutMs: 15_000,
|
timeoutMs: 15_000,
|
||||||
});
|
});
|
||||||
@@ -376,7 +312,7 @@ export async function enableTailscaleFunnel(port: number, exec: typeof runExec =
|
|||||||
|
|
||||||
export async function disableTailscaleFunnel(exec: typeof runExec = runExec) {
|
export async function disableTailscaleFunnel(exec: typeof runExec = runExec) {
|
||||||
const tailscaleBin = await getTailscaleBinary();
|
const tailscaleBin = await getTailscaleBinary();
|
||||||
await execWithSudoFallback(exec, tailscaleBin, ["funnel", "reset"], {
|
await exec(tailscaleBin, ["funnel", "reset"], {
|
||||||
maxBuffer: 200_000,
|
maxBuffer: 200_000,
|
||||||
timeoutMs: 15_000,
|
timeoutMs: 15_000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -408,15 +408,16 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
|||||||
case "new":
|
case "new":
|
||||||
case "reset":
|
case "reset":
|
||||||
try {
|
try {
|
||||||
// Clear token counts immediately to avoid stale display (#1523)
|
await client.resetSession(state.currentSessionKey);
|
||||||
state.sessionInfo.inputTokens = null;
|
// Clear token counts after reset to avoid stale display while refreshing. (#1523)
|
||||||
state.sessionInfo.outputTokens = null;
|
state.sessionInfo.inputTokens = 0;
|
||||||
state.sessionInfo.totalTokens = null;
|
state.sessionInfo.outputTokens = 0;
|
||||||
|
state.sessionInfo.totalTokens = 0;
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
|
|
||||||
await client.resetSession(state.currentSessionKey);
|
|
||||||
chatLog.addSystem(`session ${state.currentSessionKey} reset`);
|
chatLog.addSystem(`session ${state.currentSessionKey} reset`);
|
||||||
await loadHistory();
|
await loadHistory();
|
||||||
|
await refreshSessionInfo();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
chatLog.addSystem(`reset failed: ${String(err)}`);
|
chatLog.addSystem(`reset failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user