Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ Open `http://localhost:5173`.
### 4) Pair with an agent

1. Enter the WebSocket endpoint (default: `ws://127.0.0.1:32123/ws`).
2. Enter a 6-digit pairing PIN.
3. After `pairing_result`, the UI switches to chat mode.
2. If your backend requires `web.accounts.<name>.auth_token` (common for non-loopback binds), set it in the optional `auth_token` field.
3. Enter a 6-digit pairing PIN.
4. After `pairing_result`, the UI switches to chat mode.

## Scripts

Expand Down Expand Up @@ -101,7 +102,7 @@ Detailed docs:

`localStorage` keys:

- `nullclaw_ui_auth_v1` - endpoint URL, `access_token`, `shared_key`, `expires_at`.
- `nullclaw_ui_auth_v1` - endpoint URL (may include `?token=` when used), `access_token`, `shared_key`, `expires_at`.
- `nullclaw_ui_theme` - current theme.
- `nullclaw_ui_effects` - visual effects toggle.

Expand Down
3 changes: 2 additions & 1 deletion docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,15 @@ Implications:

WebSocket endpoint is currently user-entered in `PairingScreen`.
Default value: `ws://127.0.0.1:32123/ws`.
For deployments that enforce `web.accounts.<name>.auth_token`, users can provide the value via the optional `auth_token` field in the pairing form (it is appended as `?token=...` when opening the WebSocket).

To change default endpoint, update initial `url` in `src/lib/components/PairingScreen.svelte`.

## Sensitive Data Handling

`nullclaw_ui_auth_v1` in local storage includes:

- endpoint URL
- endpoint URL (may include `token` query param when configured in UI)
- `access_token`
- `shared_key` (base64url)
- `expires_at`
Expand Down
4 changes: 3 additions & 1 deletion src/lib/components/ConnectionModal.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import type { ClientState } from "$lib/protocol/client.svelte";
import { redactWebSocketAuthToken } from "$lib/protocol/ws-url";

interface Props {
state: ClientState;
Expand All @@ -9,6 +10,7 @@
}

let { state, sessionId, endpointUrl, onClose }: Props = $props();
const safeEndpointUrl = $derived(redactWebSocketAuthToken(endpointUrl));

function handleBackdropClick() {
onClose();
Expand Down Expand Up @@ -39,7 +41,7 @@
<div class="modal-body">
<div class="info-line">
<span class="label">ENDPOINT_URI:</span>
<span class="value">{endpointUrl || "UNRESOLVED"}</span>
<span class="value">{safeEndpointUrl || "UNRESOLVED"}</span>
</div>

<div class="info-line">
Expand Down
16 changes: 14 additions & 2 deletions src/lib/components/PairingScreen.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,20 @@
interface Props {
connecting?: boolean;
error?: string | null;
onConnect: (url: string, code: string) => void;
onConnect: (url: string, code: string, authToken?: string) => void;
}

let { connecting = false, error = null, onConnect }: Props = $props();

let url = $state("ws://127.0.0.1:32123/ws");
let authToken = $state("");
let code = $state("");

function handleSubmit(e: Event) {
e.preventDefault();
const trimmed = code.replace(/\s/g, "");
if (trimmed.length !== 6 || !/^\d{6}$/.test(trimmed)) return;
onConnect(url, trimmed);
onConnect(url, trimmed, authToken);
}

function handleCodeInput(e: Event) {
Expand All @@ -41,6 +42,17 @@
/>
</label>

<label class="field">
<span class="label">auth_token (optional):</span>
<input
type="password"
bind:value={authToken}
placeholder="required for non-loopback listen"
autocomplete="off"
disabled={connecting}
/>
</label>

<label class="field auth-field">
<span class="label">enter_auth_pin:</span>
<input
Expand Down
27 changes: 26 additions & 1 deletion src/lib/components/PairingScreen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe('PairingScreen', () => {
await fireEvent.submit(form as HTMLFormElement);

expect(onConnect).toHaveBeenCalledTimes(1);
expect(onConnect).toHaveBeenCalledWith('ws://127.0.0.1:32123/ws', '123456');
expect(onConnect).toHaveBeenCalledWith('ws://127.0.0.1:32123/ws', '123456', '');
});

it('does not submit when code is invalid', async () => {
Expand All @@ -46,4 +46,29 @@ describe('PairingScreen', () => {

expect(onConnect).not.toHaveBeenCalled();
});

it('passes auth token when provided', async () => {
const onConnect = vi.fn();
const { getByPlaceholderText, container } = render(PairingScreen, {
props: {
connecting: false,
error: null,
onConnect,
},
});

const tokenInput = getByPlaceholderText('required for non-loopback listen') as HTMLInputElement;
const codeInput = getByPlaceholderText('______') as HTMLInputElement;
await fireEvent.input(tokenInput, { target: { value: 'gateway-secret-token' } });
await fireEvent.input(codeInput, { target: { value: '123456' } });

const form = container.querySelector('form');
await fireEvent.submit(form as HTMLFormElement);

expect(onConnect).toHaveBeenCalledWith(
'ws://127.0.0.1:32123/ws',
'123456',
'gateway-secret-token',
);
});
});
8 changes: 3 additions & 5 deletions src/lib/protocol/e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,9 @@ export async function exportPublicKey(key: CryptoKey): Promise<string> {

async function importPublicKey(base64url: string): Promise<CryptoKey> {
const raw = fromBase64Url(base64url);
const rawBuffer: ArrayBuffer =
raw.byteOffset === 0 && raw.byteLength === raw.buffer.byteLength
? (raw.buffer as ArrayBuffer)
: raw.slice().buffer;
return crypto.subtle.importKey('raw', rawBuffer, { name: 'X25519' }, true, []);
const keyBytes = new Uint8Array(new ArrayBuffer(raw.byteLength));
keyBytes.set(raw);
return crypto.subtle.importKey('raw', keyBytes, { name: 'X25519' }, true, []);
}

export async function deriveSharedKey(
Expand Down
42 changes: 42 additions & 0 deletions src/lib/protocol/ws-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { redactWebSocketAuthToken, withWebSocketAuthToken } from './ws-url';

describe('withWebSocketAuthToken', () => {
it('appends token query parameter when missing', () => {
const result = withWebSocketAuthToken(
'ws://100.69.118.73:32123/ws',
'gateway-token',
);

expect(result).toBe('ws://100.69.118.73:32123/ws?token=gateway-token');
});

it('preserves existing token query parameter', () => {
const result = withWebSocketAuthToken(
'ws://100.69.118.73:32123/ws?token=already-set',
'gateway-token',
);

expect(result).toBe('ws://100.69.118.73:32123/ws?token=already-set');
});

it('does not modify url when token is empty', () => {
const result = withWebSocketAuthToken('ws://localhost:32123/ws', ' ');
expect(result).toBe('ws://localhost:32123/ws');
});
});

describe('redactWebSocketAuthToken', () => {
it('redacts token query parameter for diagnostics output', () => {
const result = redactWebSocketAuthToken(
'wss://host.example/ws?token=super-secret&foo=bar',
);

expect(result).toBe('wss://host.example/ws?token=***&foo=bar');
});

it('returns the original value when token is absent', () => {
const result = redactWebSocketAuthToken('wss://host.example/ws?foo=bar');
expect(result).toBe('wss://host.example/ws?foo=bar');
});
});
44 changes: 44 additions & 0 deletions src/lib/protocol/ws-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const TOKEN_QUERY_PARAM = 'token';

export function withWebSocketAuthToken(url: string, authToken?: string): string {
const trimmedUrl = url.trim();
const trimmedToken = authToken?.trim();

if (!trimmedToken) {
return trimmedUrl;
}

try {
const parsed = new URL(trimmedUrl);

if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
return trimmedUrl;
}

const existingToken = parsed.searchParams.get(TOKEN_QUERY_PARAM);
if (!existingToken) {
parsed.searchParams.set(TOKEN_QUERY_PARAM, trimmedToken);
}

return parsed.toString();
} catch {
return trimmedUrl;
}
}

export function redactWebSocketAuthToken(url?: string): string | undefined {
if (!url) return url;

try {
const parsed = new URL(url);

if (!parsed.searchParams.has(TOKEN_QUERY_PARAM)) {
return url;
}

parsed.searchParams.set(TOKEN_QUERY_PARAM, '***');
return parsed.toString();
} catch {
return url;
}
}
10 changes: 6 additions & 4 deletions src/lib/session/connection-controller.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
exportPublicKey,
generateKeyPair,
} from '$lib/protocol/e2e';
import { withWebSocketAuthToken } from '$lib/protocol/ws-url';

const DEFAULT_ENDPOINT_URL = 'ws://unknown';
const PAIRING_TIMEOUT_MS = 10_000;
Expand Down Expand Up @@ -123,16 +124,17 @@ export function createConnectionController(sessionId: string) {
});
}

async function connectWithPairing(url: string, code: string): Promise<void> {
async function connectWithPairing(url: string, code: string, authToken?: string): Promise<void> {
const connectUrl = withWebSocketAuthToken(url, authToken);
pairingError = null;
clearStoredAuth();
client?.disconnect();
session.clear();

const newClient = new NullclawClient(url, sessionId);
const handlers = attachClientHandlers(newClient, url);
const newClient = new NullclawClient(connectUrl, sessionId);
const handlers = attachClientHandlers(newClient, connectUrl);
client = newClient;
lastEndpointUrl = url;
lastEndpointUrl = connectUrl;
client.connect();

try {
Expand Down