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
- 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).
- 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.

View File

@@ -555,7 +555,7 @@ Defaults:
mode: "local", // or "remote"
bind: "loopback",
// 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" }
}
}
@@ -566,6 +566,7 @@ Notes:
Auth and Tailscale:
- `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).
- `gateway.auth.password` can be set here, or via `CLAWDIS_GATEWAY_PASSWORD` (recommended).
- `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.
- **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).
## 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**
- 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.
5) **Providers**

View File

@@ -280,8 +280,16 @@ export async function runInteractiveOnboarding(
await select({
message: "Gateway auth",
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" },
],
}),
@@ -344,6 +352,7 @@ export async function runInteractiveOnboarding(
const tokenInput = guardCancel(
await text({
message: "Gateway token (blank to generate)",
placeholder: "Needed for multi-machine or non-loopback access",
initialValue: randomToken(),
}),
runtime,
@@ -375,7 +384,11 @@ export async function runInteractiveOnboarding(
...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 = {
/** Authentication mode for Gateway connections. Defaults to token when set. */
mode?: GatewayAuthMode;
/** Shared token for token mode (stored locally for CLI auth). */
token?: string;
/** Shared password for password mode (consider env instead). */
password?: string;
/** Allow Tailscale identity headers when serve mode is enabled. */
@@ -1097,6 +1099,7 @@ const ClawdisSchema = z.object({
auth: z
.object({
mode: z.union([z.literal("token"), z.literal("password")]).optional(),
token: z.string().optional(),
password: z.string().optional(),
allowTailscale: z.boolean().optional(),
})

View File

@@ -101,7 +101,7 @@ function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
if (auth.mode === "token" && !auth.token) {
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) {

View File

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

View File

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