fix: persist gateway token for local CLI auth

This commit is contained in:
Peter Steinberger
2026-01-02 13:46:48 +01:00
parent 1e04481aaf
commit 5ecb65cbbe
9 changed files with 41 additions and 12 deletions

View File

@@ -45,6 +45,7 @@
### Fixes ### Fixes
- Chat UI: keep the chat scrolled to the latest message after switching sessions. - Chat UI: keep the chat scrolled to the latest message after switching sessions.
- CLI onboarding: persist gateway token in config so local CLI auth works; recommend auth Off unless you need multi-machine access.
- Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android). - Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android).
- Control UI: refine Web Chat session selector styling (chevron spacing + background). - Control UI: refine Web Chat session selector styling (chevron spacing + background).
- WebChat: stream live updates for sessions even when runs start outside the chat UI. - WebChat: stream live updates for sessions even when runs start outside the chat UI.

View File

@@ -555,7 +555,7 @@ Defaults:
mode: "local", // or "remote" mode: "local", // or "remote"
bind: "loopback", bind: "loopback",
// controlUi: { enabled: true } // controlUi: { enabled: true }
// auth: { mode: "token" | "password" } // auth: { mode: "token", token: "your-token" } // token is for multi-machine CLI access
// tailscale: { mode: "off" | "serve" | "funnel" } // tailscale: { mode: "off" | "serve" | "funnel" }
} }
} }
@@ -566,6 +566,7 @@ Notes:
Auth and Tailscale: Auth and Tailscale:
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`). - `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine).
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers). - When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
- `gateway.auth.password` can be set here, or via `CLAWDIS_GATEWAY_PASSWORD` (recommended). - `gateway.auth.password` can be set here, or via `CLAWDIS_GATEWAY_PASSWORD` (recommended).
- `gateway.auth.allowTailscale` controls whether Tailscale identity headers can satisfy auth. - `gateway.auth.allowTailscale` controls whether Tailscale identity headers can satisfy auth.

View File

@@ -22,6 +22,10 @@ First question: where does the **Gateway** run?
- **Local (this Mac):** onboarding can run the Anthropic OAuth flow and write the Clawdis token store locally. - **Local (this Mac):** onboarding can run the Anthropic OAuth flow and write the Clawdis token store locally.
- **Remote (over SSH/tailnet):** onboarding must not run OAuth locally, because credentials must exist on the **gateway host**. - **Remote (over SSH/tailnet):** onboarding must not run OAuth locally, because credentials must exist on the **gateway host**.
Gateway auth tip:
- If you only use Clawdis on this Mac (loopback gateway), keep auth **Off**.
- Use **Token** for multi-machine access or non-loopback binds.
Implementation note (2025-12-19): in local mode, the macOS app bundles the Gateway and enables it via a per-user launchd LaunchAgent (no global npm install/Node requirement for the user). Implementation note (2025-12-19): in local mode, the macOS app bundles the Gateway and enables it via a per-user launchd LaunchAgent (no global npm install/Node requirement for the user).
## 2) Local-only: Connect Claude (Anthropic OAuth) ## 2) Local-only: Connect Claude (Anthropic OAuth)

View File

@@ -58,6 +58,7 @@ It does **not** install or change anything on the remote host.
4) **Gateway** 4) **Gateway**
- Port, bind, auth mode, tailscale exposure. - Port, bind, auth mode, tailscale exposure.
- Auth recommendation: keep **Off** for single-machine loopback setups. Use **Token** for multi-machine access or non-loopback binds.
- Nonloopback binds require auth. - Nonloopback binds require auth.
5) **Providers** 5) **Providers**

View File

@@ -280,8 +280,16 @@ export async function runInteractiveOnboarding(
await select({ await select({
message: "Gateway auth", message: "Gateway auth",
options: [ options: [
{ value: "off", label: "Off (loopback only)" }, {
{ value: "token", label: "Token" }, value: "off",
label: "Off (loopback only)",
hint: "Recommended for single-machine setups",
},
{
value: "token",
label: "Token",
hint: "Use for multi-machine access or non-loopback binds",
},
{ value: "password", label: "Password" }, { value: "password", label: "Password" },
], ],
}), }),
@@ -344,6 +352,7 @@ export async function runInteractiveOnboarding(
const tokenInput = guardCancel( const tokenInput = guardCancel(
await text({ await text({
message: "Gateway token (blank to generate)", message: "Gateway token (blank to generate)",
placeholder: "Needed for multi-machine or non-loopback access",
initialValue: randomToken(), initialValue: randomToken(),
}), }),
runtime, runtime,
@@ -375,7 +384,11 @@ export async function runInteractiveOnboarding(
...nextConfig, ...nextConfig,
gateway: { gateway: {
...nextConfig.gateway, ...nextConfig.gateway,
auth: { ...nextConfig.gateway?.auth, mode: "token" }, auth: {
...nextConfig.gateway?.auth,
mode: "token",
token: gatewayToken,
},
}, },
}; };
} }

View File

@@ -351,6 +351,8 @@ export type GatewayAuthMode = "token" | "password";
export type GatewayAuthConfig = { export type GatewayAuthConfig = {
/** Authentication mode for Gateway connections. Defaults to token when set. */ /** Authentication mode for Gateway connections. Defaults to token when set. */
mode?: GatewayAuthMode; mode?: GatewayAuthMode;
/** Shared token for token mode (stored locally for CLI auth). */
token?: string;
/** Shared password for password mode (consider env instead). */ /** Shared password for password mode (consider env instead). */
password?: string; password?: string;
/** Allow Tailscale identity headers when serve mode is enabled. */ /** Allow Tailscale identity headers when serve mode is enabled. */
@@ -1097,6 +1099,7 @@ const ClawdisSchema = z.object({
auth: z auth: z
.object({ .object({
mode: z.union([z.literal("token"), z.literal("password")]).optional(), mode: z.union([z.literal("token"), z.literal("password")]).optional(),
token: z.string().optional(),
password: z.string().optional(), password: z.string().optional(),
allowTailscale: z.boolean().optional(), allowTailscale: z.boolean().optional(),
}) })

View File

@@ -101,7 +101,7 @@ function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void { export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
if (auth.mode === "token" && !auth.token) { if (auth.mode === "token" && !auth.token) {
throw new Error( throw new Error(
"gateway auth mode is token, but CLAWDIS_GATEWAY_TOKEN is not set", "gateway auth mode is token, but no token was configured (set gateway.auth.token or CLAWDIS_GATEWAY_TOKEN)",
); );
} }
if (auth.mode === "password" && !auth.password) { if (auth.mode === "password" && !auth.password) {

View File

@@ -25,8 +25,8 @@ export async function callGateway<T = unknown>(
): Promise<T> { ): Promise<T> {
const timeoutMs = opts.timeoutMs ?? 10_000; const timeoutMs = opts.timeoutMs ?? 10_000;
const config = loadConfig(); const config = loadConfig();
const remote = const isRemoteMode = config.gateway?.mode === "remote";
config.gateway?.mode === "remote" ? config.gateway.remote : undefined; const remote = isRemoteMode ? config.gateway.remote : undefined;
const url = const url =
(typeof opts.url === "string" && opts.url.trim().length > 0 (typeof opts.url === "string" && opts.url.trim().length > 0
? opts.url.trim() ? opts.url.trim()
@@ -39,9 +39,15 @@ export async function callGateway<T = unknown>(
(typeof opts.token === "string" && opts.token.trim().length > 0 (typeof opts.token === "string" && opts.token.trim().length > 0
? opts.token.trim() ? opts.token.trim()
: undefined) || : undefined) ||
(typeof remote?.token === "string" && remote.token.trim().length > 0 (isRemoteMode
? remote.token.trim() ? typeof remote?.token === "string" && remote.token.trim().length > 0
: undefined); ? remote.token.trim()
: undefined
: process.env.CLAWDIS_GATEWAY_TOKEN?.trim() ||
(typeof config.gateway?.auth?.token === "string" &&
config.gateway.auth.token.trim().length > 0
? config.gateway.auth.token.trim()
: undefined));
const password = const password =
(typeof opts.password === "string" && opts.password.trim().length > 0 (typeof opts.password === "string" && opts.password.trim().length > 0
? opts.password.trim() ? opts.password.trim()

View File

@@ -660,7 +660,6 @@ type DedupeEntry = {
error?: ErrorShape; error?: ErrorShape;
}; };
const getGatewayToken = () => process.env.CLAWDIS_GATEWAY_TOKEN;
function formatForLog(value: unknown): string { function formatForLog(value: unknown): string {
try { try {
@@ -1371,7 +1370,8 @@ export async function startGatewayServer(
...tailscaleOverrides, ...tailscaleOverrides,
}; };
const tailscaleMode = tailscaleConfig.mode ?? "off"; const tailscaleMode = tailscaleConfig.mode ?? "off";
const token = getGatewayToken(); const token =
authConfig.token ?? process.env.CLAWDIS_GATEWAY_TOKEN ?? undefined;
const password = const password =
authConfig.password ?? process.env.CLAWDIS_GATEWAY_PASSWORD ?? undefined; authConfig.password ?? process.env.CLAWDIS_GATEWAY_PASSWORD ?? undefined;
const authMode: ResolvedGatewayAuth["mode"] = const authMode: ResolvedGatewayAuth["mode"] =