Merge branch 'main' into feat/mattermost-channel
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
evaluateSessionFreshness,
|
||||
loadSessionStore,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveChannelResetConfig,
|
||||
resolveExplicitAgentSessionKey,
|
||||
resolveSessionResetPolicy,
|
||||
resolveSessionResetType,
|
||||
@@ -99,7 +100,15 @@ export function resolveSession(opts: {
|
||||
const sessionEntry = sessionKey ? sessionStore[sessionKey] : undefined;
|
||||
|
||||
const resetType = resolveSessionResetType({ sessionKey });
|
||||
const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType });
|
||||
const channelReset = resolveChannelResetConfig({
|
||||
sessionCfg,
|
||||
channel: sessionEntry?.lastChannel ?? sessionEntry?.channel,
|
||||
});
|
||||
const resetPolicy = resolveSessionResetPolicy({
|
||||
sessionCfg,
|
||||
resetType,
|
||||
resetOverride: channelReset,
|
||||
});
|
||||
const fresh = sessionEntry
|
||||
? evaluateSessionFreshness({ updatedAt: sessionEntry.updatedAt, now, policy: resetPolicy })
|
||||
.fresh
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { identityHasValues, parseIdentityMarkdown } from "../agents/identity-file.js";
|
||||
import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js";
|
||||
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
|
||||
import type { IdentityConfig } from "../config/types.js";
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
findAgentEntryIndex,
|
||||
listAgentEntries,
|
||||
loadAgentIdentity,
|
||||
parseIdentityMarkdown,
|
||||
} from "./agents.config.js";
|
||||
|
||||
type AgentsSetIdentityOptions = {
|
||||
@@ -25,6 +25,7 @@ type AgentsSetIdentityOptions = {
|
||||
name?: string;
|
||||
emoji?: string;
|
||||
theme?: string;
|
||||
avatar?: string;
|
||||
fromIdentity?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
@@ -40,7 +41,7 @@ async function loadIdentityFromFile(filePath: string): Promise<AgentIdentity | n
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
const parsed = parseIdentityMarkdown(content);
|
||||
if (!parsed.name && !parsed.emoji && !parsed.theme && !parsed.creature && !parsed.vibe) {
|
||||
if (!identityHasValues(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
@@ -75,7 +76,8 @@ export async function agentsSetIdentityCommand(
|
||||
const nameRaw = coerceTrimmed(opts.name);
|
||||
const emojiRaw = coerceTrimmed(opts.emoji);
|
||||
const themeRaw = coerceTrimmed(opts.theme);
|
||||
const hasExplicitIdentity = Boolean(nameRaw || emojiRaw || themeRaw);
|
||||
const avatarRaw = coerceTrimmed(opts.avatar);
|
||||
const hasExplicitIdentity = Boolean(nameRaw || emojiRaw || themeRaw || avatarRaw);
|
||||
|
||||
const identityFileRaw = coerceTrimmed(opts.identityFile);
|
||||
const workspaceRaw = coerceTrimmed(opts.workspace);
|
||||
@@ -141,10 +143,20 @@ export async function agentsSetIdentityCommand(
|
||||
...(nameRaw || identityFromFile?.name ? { name: nameRaw ?? identityFromFile?.name } : {}),
|
||||
...(emojiRaw || identityFromFile?.emoji ? { emoji: emojiRaw ?? identityFromFile?.emoji } : {}),
|
||||
...(themeRaw || fileTheme ? { theme: themeRaw ?? fileTheme } : {}),
|
||||
...(avatarRaw || identityFromFile?.avatar
|
||||
? { avatar: avatarRaw ?? identityFromFile?.avatar }
|
||||
: {}),
|
||||
};
|
||||
|
||||
if (!incomingIdentity.name && !incomingIdentity.emoji && !incomingIdentity.theme) {
|
||||
runtime.error("No identity fields provided. Use --name/--emoji/--theme or --from-identity.");
|
||||
if (
|
||||
!incomingIdentity.name &&
|
||||
!incomingIdentity.emoji &&
|
||||
!incomingIdentity.theme &&
|
||||
!incomingIdentity.avatar
|
||||
) {
|
||||
runtime.error(
|
||||
"No identity fields provided. Use --name/--emoji/--theme/--avatar or --from-identity.",
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
@@ -204,5 +216,6 @@ export async function agentsSetIdentityCommand(
|
||||
if (nextIdentity.name) runtime.log(`Name: ${nextIdentity.name}`);
|
||||
if (nextIdentity.theme) runtime.log(`Theme: ${nextIdentity.theme}`);
|
||||
if (nextIdentity.emoji) runtime.log(`Emoji: ${nextIdentity.emoji}`);
|
||||
if (nextIdentity.avatar) runtime.log(`Avatar: ${nextIdentity.avatar}`);
|
||||
if (workspaceDir) runtime.log(`Workspace: ${workspaceDir}`);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
resolveAgentDir,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "../agents/agent-scope.js";
|
||||
import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js";
|
||||
import type { AgentIdentityFile } from "../agents/identity-file.js";
|
||||
import {
|
||||
identityHasValues,
|
||||
loadAgentIdentityFromWorkspace,
|
||||
parseIdentityMarkdown as parseIdentityMarkdownFile,
|
||||
} from "../agents/identity-file.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
|
||||
@@ -28,13 +30,7 @@ export type AgentSummary = {
|
||||
|
||||
type AgentEntry = NonNullable<NonNullable<ClawdbotConfig["agents"]>["list"]>[number];
|
||||
|
||||
export type AgentIdentity = {
|
||||
name?: string;
|
||||
emoji?: string;
|
||||
creature?: string;
|
||||
vibe?: string;
|
||||
theme?: string;
|
||||
};
|
||||
export type AgentIdentity = AgentIdentityFile;
|
||||
|
||||
export function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] {
|
||||
const list = cfg.agents?.list;
|
||||
@@ -73,39 +69,13 @@ function resolveAgentModel(cfg: ClawdbotConfig, agentId: string) {
|
||||
}
|
||||
|
||||
export function parseIdentityMarkdown(content: string): AgentIdentity {
|
||||
const identity: AgentIdentity = {};
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const cleaned = line.trim().replace(/^\s*-\s*/, "");
|
||||
const colonIndex = cleaned.indexOf(":");
|
||||
if (colonIndex === -1) continue;
|
||||
const label = cleaned.slice(0, colonIndex).replace(/[*_]/g, "").trim().toLowerCase();
|
||||
const value = cleaned
|
||||
.slice(colonIndex + 1)
|
||||
.replace(/^[*_]+|[*_]+$/g, "")
|
||||
.trim();
|
||||
if (!value) continue;
|
||||
if (label === "name") identity.name = value;
|
||||
if (label === "emoji") identity.emoji = value;
|
||||
if (label === "creature") identity.creature = value;
|
||||
if (label === "vibe") identity.vibe = value;
|
||||
if (label === "theme") identity.theme = value;
|
||||
}
|
||||
return identity;
|
||||
return parseIdentityMarkdownFile(content);
|
||||
}
|
||||
|
||||
export function loadAgentIdentity(workspace: string): AgentIdentity | null {
|
||||
const identityPath = path.join(workspace, DEFAULT_IDENTITY_FILENAME);
|
||||
try {
|
||||
const content = fs.readFileSync(identityPath, "utf-8");
|
||||
const parsed = parseIdentityMarkdown(content);
|
||||
if (!parsed.name && !parsed.emoji && !parsed.theme && !parsed.creature && !parsed.vibe) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const parsed = loadAgentIdentityFromWorkspace(workspace);
|
||||
if (!parsed) return null;
|
||||
return identityHasValues(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function buildAgentSummaries(cfg: ClawdbotConfig): AgentSummary[] {
|
||||
|
||||
@@ -54,7 +54,13 @@ describe("agents set-identity command", () => {
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workspace, "IDENTITY.md"),
|
||||
["- Name: Clawd", "- Creature: helpful sloth", "- Emoji: :)", ""].join("\n"),
|
||||
[
|
||||
"- Name: Clawd",
|
||||
"- Creature: helpful sloth",
|
||||
"- Emoji: :)",
|
||||
"- Avatar: avatars/clawd.png",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
@@ -81,6 +87,7 @@ describe("agents set-identity command", () => {
|
||||
name: "Clawd",
|
||||
theme: "helpful sloth",
|
||||
emoji: ":)",
|
||||
avatar: "avatars/clawd.png",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,7 +122,13 @@ describe("agents set-identity command", () => {
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workspace, "IDENTITY.md"),
|
||||
["- Name: Clawd", "- Theme: space lobster", "- Emoji: :)", ""].join("\n"),
|
||||
[
|
||||
"- Name: Clawd",
|
||||
"- Theme: space lobster",
|
||||
"- Emoji: :)",
|
||||
"- Avatar: avatars/clawd.png",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
@@ -125,7 +138,13 @@ describe("agents set-identity command", () => {
|
||||
});
|
||||
|
||||
await agentsSetIdentityCommand(
|
||||
{ workspace, fromIdentity: true, name: "Nova", emoji: "🦞" },
|
||||
{
|
||||
workspace,
|
||||
fromIdentity: true,
|
||||
name: "Nova",
|
||||
emoji: "🦞",
|
||||
avatar: "https://example.com/override.png",
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
@@ -137,6 +156,7 @@ describe("agents set-identity command", () => {
|
||||
name: "Nova",
|
||||
theme: "space lobster",
|
||||
emoji: "🦞",
|
||||
avatar: "https://example.com/override.png",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,9 +167,13 @@ describe("agents set-identity command", () => {
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.writeFile(
|
||||
identityPath,
|
||||
["- **Name:** C-3PO", "- **Creature:** Flustered Protocol Droid", "- **Emoji:** 🤖", ""].join(
|
||||
"\n",
|
||||
),
|
||||
[
|
||||
"- **Name:** C-3PO",
|
||||
"- **Creature:** Flustered Protocol Droid",
|
||||
"- **Emoji:** 🤖",
|
||||
"- **Avatar:** avatars/c3po.png",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
@@ -168,6 +192,53 @@ describe("agents set-identity command", () => {
|
||||
name: "C-3PO",
|
||||
theme: "Flustered Protocol Droid",
|
||||
emoji: "🤖",
|
||||
avatar: "avatars/c3po.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts avatar-only identity from IDENTITY.md", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-identity-"));
|
||||
const workspace = path.join(root, "work");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workspace, "IDENTITY.md"),
|
||||
"- Avatar: avatars/only.png\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
configMocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
...baseSnapshot,
|
||||
config: { agents: { list: [{ id: "main", workspace }] } },
|
||||
});
|
||||
|
||||
await agentsSetIdentityCommand({ workspace, fromIdentity: true }, runtime);
|
||||
|
||||
const written = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
agents?: { list?: Array<{ id: string; identity?: Record<string, string> }> };
|
||||
};
|
||||
const main = written.agents?.list?.find((entry) => entry.id === "main");
|
||||
expect(main?.identity).toEqual({
|
||||
avatar: "avatars/only.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts avatar-only updates via flags", async () => {
|
||||
configMocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
...baseSnapshot,
|
||||
config: { agents: { list: [{ id: "main" }] } },
|
||||
});
|
||||
|
||||
await agentsSetIdentityCommand(
|
||||
{ agent: "main", avatar: "https://example.com/avatar.png" },
|
||||
runtime,
|
||||
);
|
||||
|
||||
const written = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
agents?: { list?: Array<{ id: string; identity?: Record<string, string> }> };
|
||||
};
|
||||
const main = written.agents?.list?.find((entry) => entry.id === "main");
|
||||
expect(main?.identity).toEqual({
|
||||
avatar: "https://example.com/avatar.png",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ const AUTH_CHOICE_GROUP_DEFS: {
|
||||
value: "anthropic",
|
||||
label: "Anthropic",
|
||||
hint: "Claude Code CLI + API key",
|
||||
choices: ["claude-cli", "setup-token", "token", "apiKey"],
|
||||
choices: ["claude-cli", "token", "apiKey"],
|
||||
},
|
||||
{
|
||||
value: "minimax",
|
||||
@@ -170,12 +170,6 @@ export function buildAuthChoiceOptions(params: {
|
||||
});
|
||||
}
|
||||
|
||||
options.push({
|
||||
value: "setup-token",
|
||||
label: "Anthropic token (run setup-token)",
|
||||
hint: "runs `claude setup-token` · opens browser for fresh OAuth login",
|
||||
});
|
||||
|
||||
options.push({
|
||||
value: "token",
|
||||
label: "Anthropic token (paste setup-token)",
|
||||
|
||||
@@ -37,14 +37,14 @@ export async function maybeInstallDaemon(params: {
|
||||
);
|
||||
if (action === "restart") {
|
||||
await withProgress(
|
||||
{ label: "Gateway daemon", indeterminate: true, delayMs: 0 },
|
||||
{ label: "Gateway service", indeterminate: true, delayMs: 0 },
|
||||
async (progress) => {
|
||||
progress.setLabel("Restarting Gateway daemon…");
|
||||
progress.setLabel("Restarting Gateway service…");
|
||||
await service.restart({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
});
|
||||
progress.setLabel("Gateway daemon restarted.");
|
||||
progress.setLabel("Gateway service restarted.");
|
||||
},
|
||||
);
|
||||
shouldCheckLinger = true;
|
||||
@@ -53,11 +53,11 @@ export async function maybeInstallDaemon(params: {
|
||||
if (action === "skip") return;
|
||||
if (action === "reinstall") {
|
||||
await withProgress(
|
||||
{ label: "Gateway daemon", indeterminate: true, delayMs: 0 },
|
||||
{ label: "Gateway service", indeterminate: true, delayMs: 0 },
|
||||
async (progress) => {
|
||||
progress.setLabel("Uninstalling Gateway daemon…");
|
||||
progress.setLabel("Uninstalling Gateway service…");
|
||||
await service.uninstall({ env: process.env, stdout: process.stdout });
|
||||
progress.setLabel("Gateway daemon uninstalled.");
|
||||
progress.setLabel("Gateway service uninstalled.");
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -66,12 +66,12 @@ export async function maybeInstallDaemon(params: {
|
||||
if (shouldInstall) {
|
||||
let installError: string | null = null;
|
||||
await withProgress(
|
||||
{ label: "Gateway daemon", indeterminate: true, delayMs: 0 },
|
||||
{ label: "Gateway service", indeterminate: true, delayMs: 0 },
|
||||
async (progress) => {
|
||||
if (!params.daemonRuntime) {
|
||||
daemonRuntime = guardCancel(
|
||||
await select({
|
||||
message: "Gateway daemon runtime",
|
||||
message: "Gateway service runtime",
|
||||
options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||
initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
}),
|
||||
@@ -79,7 +79,7 @@ export async function maybeInstallDaemon(params: {
|
||||
) as GatewayDaemonRuntime;
|
||||
}
|
||||
|
||||
progress.setLabel("Preparing Gateway daemon…");
|
||||
progress.setLabel("Preparing Gateway service…");
|
||||
|
||||
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
||||
env: process.env,
|
||||
@@ -89,7 +89,7 @@ export async function maybeInstallDaemon(params: {
|
||||
warn: (message, title) => note(message, title),
|
||||
});
|
||||
|
||||
progress.setLabel("Installing Gateway daemon…");
|
||||
progress.setLabel("Installing Gateway service…");
|
||||
try {
|
||||
await service.install({
|
||||
env: process.env,
|
||||
@@ -98,15 +98,15 @@ export async function maybeInstallDaemon(params: {
|
||||
workingDirectory,
|
||||
environment,
|
||||
});
|
||||
progress.setLabel("Gateway daemon installed.");
|
||||
progress.setLabel("Gateway service installed.");
|
||||
} catch (err) {
|
||||
installError = err instanceof Error ? err.message : String(err);
|
||||
progress.setLabel("Gateway daemon install failed.");
|
||||
progress.setLabel("Gateway service install failed.");
|
||||
}
|
||||
},
|
||||
);
|
||||
if (installError) {
|
||||
note("Gateway daemon install failed: " + installError, "Gateway");
|
||||
note("Gateway service install failed: " + installError, "Gateway");
|
||||
note(gatewayInstallErrorHint(), "Gateway");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -31,21 +31,26 @@ export async function promptGatewayConfig(
|
||||
await select({
|
||||
message: "Gateway bind mode",
|
||||
options: [
|
||||
{
|
||||
value: "loopback",
|
||||
label: "Loopback (Local only)",
|
||||
hint: "Bind to 127.0.0.1 - secure, local-only access",
|
||||
},
|
||||
{
|
||||
value: "tailnet",
|
||||
label: "Tailnet (Tailscale IP)",
|
||||
hint: "Bind to your Tailscale IP only (100.x.x.x)",
|
||||
},
|
||||
{
|
||||
value: "auto",
|
||||
label: "Auto (Tailnet → LAN)",
|
||||
hint: "Prefer Tailnet IP, fall back to all interfaces if unavailable",
|
||||
label: "Auto (Loopback → LAN)",
|
||||
hint: "Prefer loopback; fall back to all interfaces if unavailable",
|
||||
},
|
||||
{
|
||||
value: "lan",
|
||||
label: "LAN (All interfaces)",
|
||||
hint: "Bind to 0.0.0.0 - accessible from anywhere on your network",
|
||||
},
|
||||
{
|
||||
value: "loopback",
|
||||
label: "Loopback (Local only)",
|
||||
hint: "Bind to 127.0.0.1 - secure, local-only access",
|
||||
},
|
||||
{
|
||||
value: "custom",
|
||||
label: "Custom IP",
|
||||
@@ -54,7 +59,7 @@ export async function promptGatewayConfig(
|
||||
],
|
||||
}),
|
||||
runtime,
|
||||
) as "auto" | "lan" | "loopback" | "custom";
|
||||
) as "auto" | "lan" | "loopback" | "custom" | "tailnet";
|
||||
|
||||
let customBindHost: string | undefined;
|
||||
if (bind === "custom") {
|
||||
|
||||
@@ -359,7 +359,7 @@ export async function runConfigureWizard(
|
||||
if (!selected.includes("gateway")) {
|
||||
const portInput = guardCancel(
|
||||
await text({
|
||||
message: "Gateway port for daemon install",
|
||||
message: "Gateway port for service install",
|
||||
initialValue: String(gatewayPort),
|
||||
validate: (value) => (Number.isFinite(Number(value)) ? undefined : "Invalid port"),
|
||||
}),
|
||||
@@ -481,7 +481,7 @@ export async function runConfigureWizard(
|
||||
if (!didConfigureGateway) {
|
||||
const portInput = guardCancel(
|
||||
await text({
|
||||
message: "Gateway port for daemon install",
|
||||
message: "Gateway port for service install",
|
||||
initialValue: String(gatewayPort),
|
||||
validate: (value) => (Number.isFinite(Number(value)) ? undefined : "Invalid port"),
|
||||
}),
|
||||
|
||||
@@ -100,6 +100,6 @@ describe("buildGatewayInstallPlan", () => {
|
||||
describe("gatewayInstallErrorHint", () => {
|
||||
it("returns platform-specific hints", () => {
|
||||
expect(gatewayInstallErrorHint("win32")).toContain("Run as administrator");
|
||||
expect(gatewayInstallErrorHint("linux")).toContain("clawdbot daemon install");
|
||||
expect(gatewayInstallErrorHint("linux")).toContain("clawdbot gateway install");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,6 +65,6 @@ export async function buildGatewayInstallPlan(params: {
|
||||
|
||||
export function gatewayInstallErrorHint(platform = process.platform): string {
|
||||
return platform === "win32"
|
||||
? "Tip: rerun from an elevated PowerShell (Start → type PowerShell → right-click → Run as administrator) or skip daemon install."
|
||||
: `Tip: rerun \`${formatCliCommand("clawdbot daemon install")}\` after fixing the error.`;
|
||||
? "Tip: rerun from an elevated PowerShell (Start → type PowerShell → right-click → Run as administrator) or skip service install."
|
||||
: `Tip: rerun \`${formatCliCommand("clawdbot gateway install")}\` after fixing the error.`;
|
||||
}
|
||||
|
||||
@@ -70,10 +70,10 @@ export function buildGatewayRuntimeHints(
|
||||
hints.push(
|
||||
`LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${label}`,
|
||||
);
|
||||
hints.push(`Then reinstall: ${formatCliCommand("clawdbot daemon install", env)}`);
|
||||
hints.push(`Then reinstall: ${formatCliCommand("clawdbot gateway install", env)}`);
|
||||
}
|
||||
if (runtime.missingUnit) {
|
||||
hints.push(`Service not installed. Run: ${formatCliCommand("clawdbot daemon install", env)}`);
|
||||
hints.push(`Service not installed. Run: ${formatCliCommand("clawdbot gateway install", env)}`);
|
||||
if (fileLog) hints.push(`File logs: ${fileLog}`);
|
||||
return hints;
|
||||
}
|
||||
|
||||
@@ -139,16 +139,16 @@ export async function maybeRepairGatewayDaemon(params: {
|
||||
return;
|
||||
}
|
||||
}
|
||||
note("Gateway daemon not installed.", "Gateway");
|
||||
note("Gateway service not installed.", "Gateway");
|
||||
if (params.cfg.gateway?.mode !== "remote") {
|
||||
const install = await params.prompter.confirmSkipInNonInteractive({
|
||||
message: "Install gateway daemon now?",
|
||||
message: "Install gateway service now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (install) {
|
||||
const daemonRuntime = await params.prompter.select<GatewayDaemonRuntime>(
|
||||
{
|
||||
message: "Gateway daemon runtime",
|
||||
message: "Gateway service runtime",
|
||||
options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||
initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
},
|
||||
@@ -171,7 +171,7 @@ export async function maybeRepairGatewayDaemon(params: {
|
||||
environment,
|
||||
});
|
||||
} catch (err) {
|
||||
note(`Gateway daemon install failed: ${String(err)}`, "Gateway");
|
||||
note(`Gateway service install failed: ${String(err)}`, "Gateway");
|
||||
note(gatewayInstallErrorHint(), "Gateway");
|
||||
}
|
||||
}
|
||||
@@ -193,7 +193,7 @@ export async function maybeRepairGatewayDaemon(params: {
|
||||
|
||||
if (serviceRuntime?.status !== "running") {
|
||||
const start = await params.prompter.confirmSkipInNonInteractive({
|
||||
message: "Start gateway daemon now?",
|
||||
message: "Start gateway service now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (start) {
|
||||
@@ -208,14 +208,14 @@ export async function maybeRepairGatewayDaemon(params: {
|
||||
if (process.platform === "darwin") {
|
||||
const label = resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE);
|
||||
note(
|
||||
`LaunchAgent loaded; stopping requires "${formatCliCommand("clawdbot daemon stop")}" or launchctl bootout gui/$UID/${label}.`,
|
||||
`LaunchAgent loaded; stopping requires "${formatCliCommand("clawdbot gateway stop")}" or launchctl bootout gui/$UID/${label}.`,
|
||||
"Gateway",
|
||||
);
|
||||
}
|
||||
|
||||
if (serviceRuntime?.status === "running") {
|
||||
const restart = await params.prompter.confirmSkipInNonInteractive({
|
||||
message: "Restart gateway daemon now?",
|
||||
message: "Restart gateway service now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (restart) {
|
||||
|
||||
@@ -97,7 +97,7 @@ export async function maybeMigrateLegacyGatewayService(
|
||||
|
||||
const daemonRuntime = await prompter.select<GatewayDaemonRuntime>(
|
||||
{
|
||||
message: "Gateway daemon runtime",
|
||||
message: "Gateway service runtime",
|
||||
options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||
initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
},
|
||||
@@ -120,7 +120,7 @@ export async function maybeMigrateLegacyGatewayService(
|
||||
environment,
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error(`Gateway daemon install failed: ${String(err)}`);
|
||||
runtime.error(`Gateway service install failed: ${String(err)}`);
|
||||
note(gatewayInstallErrorHint(), "Gateway");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,19 @@ export async function doctorCommand(
|
||||
});
|
||||
let cfg: ClawdbotConfig = configResult.cfg;
|
||||
|
||||
const configPath = configResult.path ?? CONFIG_PATH_CLAWDBOT;
|
||||
if (!cfg.gateway?.mode) {
|
||||
const lines = [
|
||||
"gateway.mode is unset; gateway start will be blocked.",
|
||||
`Fix: run ${formatCliCommand("clawdbot configure")} and set Gateway mode (local/remote).`,
|
||||
`Or set directly: ${formatCliCommand("clawdbot config set gateway.mode local")}`,
|
||||
];
|
||||
if (!fs.existsSync(configPath)) {
|
||||
lines.push(`Missing config: run ${formatCliCommand("clawdbot setup")} first.`);
|
||||
}
|
||||
note(lines.join("\n"), "Gateway");
|
||||
}
|
||||
|
||||
cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter);
|
||||
await noteAuthProfileHealth({
|
||||
cfg,
|
||||
|
||||
@@ -181,6 +181,10 @@ describe("gateway-status command", () => {
|
||||
|
||||
expect(startSshPortForward).toHaveBeenCalledTimes(1);
|
||||
expect(probeGateway).toHaveBeenCalled();
|
||||
const tunnelCall = probeGateway.mock.calls.find(
|
||||
(call) => typeof call?.[0]?.url === "string" && call[0].url.startsWith("ws://127.0.0.1:"),
|
||||
)?.[0] as { auth?: { token?: string } } | undefined;
|
||||
expect(tunnelCall?.auth?.token).toBe("rtok");
|
||||
expect(sshStop).toHaveBeenCalledTimes(1);
|
||||
|
||||
const parsed = JSON.parse(runtimeLogs.join("\n")) as Record<string, unknown>;
|
||||
|
||||
@@ -134,7 +134,7 @@ export function resolveAuthForTarget(
|
||||
return { token: tokenOverride, password: passwordOverride };
|
||||
}
|
||||
|
||||
if (target.kind === "configRemote") {
|
||||
if (target.kind === "configRemote" || target.kind === "sshTunnel") {
|
||||
const token =
|
||||
typeof cfg.gateway?.remote?.token === "string" ? cfg.gateway.remote.token.trim() : "";
|
||||
const remotePassword = (cfg.gateway?.remote as { password?: unknown } | undefined)?.password;
|
||||
|
||||
@@ -71,4 +71,24 @@ describe("resolveControlUiLinks", () => {
|
||||
expect(links.httpUrl).toBe("http://127.0.0.1:18789/");
|
||||
expect(links.wsUrl).toBe("ws://127.0.0.1:18789");
|
||||
});
|
||||
|
||||
it("uses tailnet IP for tailnet bind", () => {
|
||||
mocks.pickPrimaryTailnetIPv4.mockReturnValueOnce("100.64.0.9");
|
||||
const links = resolveControlUiLinks({
|
||||
port: 18789,
|
||||
bind: "tailnet",
|
||||
});
|
||||
expect(links.httpUrl).toBe("http://100.64.0.9:18789/");
|
||||
expect(links.wsUrl).toBe("ws://100.64.0.9:18789");
|
||||
});
|
||||
|
||||
it("keeps loopback for auto even when tailnet is present", () => {
|
||||
mocks.pickPrimaryTailnetIPv4.mockReturnValueOnce("100.64.0.9");
|
||||
const links = resolveControlUiLinks({
|
||||
port: 18789,
|
||||
bind: "auto",
|
||||
});
|
||||
expect(links.httpUrl).toBe("http://127.0.0.1:18789/");
|
||||
expect(links.wsUrl).toBe("ws://127.0.0.1:18789");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -366,7 +366,7 @@ export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR;
|
||||
|
||||
export function resolveControlUiLinks(params: {
|
||||
port: number;
|
||||
bind?: "auto" | "lan" | "loopback" | "custom";
|
||||
bind?: "auto" | "lan" | "loopback" | "custom" | "tailnet";
|
||||
customBindHost?: string;
|
||||
basePath?: string;
|
||||
}): { httpUrl: string; wsUrl: string } {
|
||||
@@ -378,7 +378,7 @@ export function resolveControlUiLinks(params: {
|
||||
if (bind === "custom" && customBindHost && isValidIPv4(customBindHost)) {
|
||||
return customBindHost;
|
||||
}
|
||||
if (bind === "auto" && tailnetIPv4) return tailnetIPv4 ?? "127.0.0.1";
|
||||
if (bind === "tailnet" && tailnetIPv4) return tailnetIPv4 ?? "127.0.0.1";
|
||||
return "127.0.0.1";
|
||||
})();
|
||||
const basePath = normalizeControlUiBasePath(params.basePath);
|
||||
|
||||
@@ -91,7 +91,7 @@ export async function runNonInteractiveOnboardingLocal(params: {
|
||||
const daemonRuntimeRaw = opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME;
|
||||
if (!opts.skipHealth) {
|
||||
const links = resolveControlUiLinks({
|
||||
bind: gatewayResult.bind as "auto" | "lan" | "loopback" | "custom",
|
||||
bind: gatewayResult.bind as "auto" | "lan" | "loopback" | "custom" | "tailnet",
|
||||
port: gatewayResult.port,
|
||||
customBindHost: nextConfig.gateway?.customBindHost,
|
||||
basePath: undefined,
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function installGatewayDaemonNonInteractive(params: {
|
||||
const systemdAvailable =
|
||||
process.platform === "linux" ? await isSystemdUserServiceAvailable() : true;
|
||||
if (process.platform === "linux" && !systemdAvailable) {
|
||||
runtime.log("Systemd user services are unavailable; skipping daemon install.");
|
||||
runtime.log("Systemd user services are unavailable; skipping service install.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export async function installGatewayDaemonNonInteractive(params: {
|
||||
environment,
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error(`Gateway daemon install failed: ${String(err)}`);
|
||||
runtime.error(`Gateway service install failed: ${String(err)}`);
|
||||
runtime.log(gatewayInstallErrorHint());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export type AuthChoice =
|
||||
| "skip";
|
||||
export type GatewayAuthChoice = "off" | "token" | "password";
|
||||
export type ResetScope = "config" | "config+creds+sessions" | "full";
|
||||
export type GatewayBind = "loopback" | "lan" | "auto" | "custom";
|
||||
export type GatewayBind = "loopback" | "lan" | "auto" | "custom" | "tailnet";
|
||||
export type TailscaleMode = "off" | "serve" | "funnel";
|
||||
export type NodeManagerChoice = "npm" | "pnpm" | "bun";
|
||||
export type ChannelChoice = ChannelId;
|
||||
|
||||
@@ -574,6 +574,6 @@ export async function statusCommand(
|
||||
if (gatewayReachable) {
|
||||
runtime.log(` Need to test channels? ${formatCliCommand("clawdbot status --deep")}`);
|
||||
} else {
|
||||
runtime.log(` Fix reachability first: ${formatCliCommand("clawdbot gateway status")}`);
|
||||
runtime.log(` Fix reachability first: ${formatCliCommand("clawdbot gateway probe")}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ function buildScopeSelection(opts: UninstallOptions): {
|
||||
|
||||
async function stopAndUninstallService(runtime: RuntimeEnv): Promise<boolean> {
|
||||
if (isNixMode) {
|
||||
runtime.error("Nix mode detected; daemon uninstall is disabled.");
|
||||
runtime.error("Nix mode detected; service uninstall is disabled.");
|
||||
return false;
|
||||
}
|
||||
const service = resolveGatewayService();
|
||||
|
||||
Reference in New Issue
Block a user