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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,31 @@ The plugin creates a full commented template at this path on first startup. This
- `scope: "all-projects"`: query `search` / `list` across all project shards.
- `memory.defaultScope` sets the default query scope when no explicit scope is provided.

### Web UI HTTP Basic Auth

When `webServerHost` is set to anything other than loopback (for example `0.0.0.0`), the web UI is reachable by anyone on the network. To keep your memories off the LAN, gate the web server with HTTP Basic Auth via the same config file used for everything else:

```jsonc
{
"webServerHost": "0.0.0.0", // optional: reach the UI from the LAN
"webServerAuthPassword": "pick-a-strong-one",
"webServerAuthUsername": "admin", // optional, defaults to the current OS user
}
```

| Field | Default | Effect |
| ----------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `webServerAuthPassword` | _(empty)_ | When set, the server demands HTTP Basic Auth credentials on every request. Leave empty to keep the open-by-default behavior. |
| `webServerAuthUsername` | OS user (`$USER`) | Username required by the Basic Auth challenge. |

`webServerAuthPassword` accepts the same secret formats as `memoryApiKey`:

- a literal string (simple, fine for personal machines),
- `env://SOME_ENV_VAR` to pull the value from the environment at startup,
- `file:///path/to/secret` to read it from a file (`chmod 600` recommended — the plugin will warn if the file is world-readable).

The browser will pop its native Basic Auth dialog and remember the credentials for the current session; closing all browser windows discards them, so reopening the browser requires signing in again. Credentials are compared with a constant-time check, and the unauthenticated 401 response carries `Cache-Control: no-store` so no intermediate cache will replay it. CORS is also relaxed once auth is on, so other tools on the same LAN can talk to the API after authenticating.

### Sharing One Project Memory Across Nested Repos

By default a project is identified by its enclosing git repository, so every
Expand Down
52 changes: 52 additions & 0 deletions scripts/test-auth-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { WebServer } from "../src/services/web-server.js";
import { WebAuth } from "../src/services/web-auth.js";
import { resolveSecretValue } from "../src/services/secret-resolver.js";

const PORT = Number(process.env.PORT ?? 14747);
const HOST = process.env.HOST ?? "127.0.0.1";

// Mirrors how src/index.ts plumbs the opencode-mem config through to WebAuth:
// both values are optional in the config file, and `webServerAuthPassword`
// accepts the same env:// / file:// shorthand as `memoryApiKey`.
const password = resolveSecretValue(process.env.WEB_AUTH_PASSWORD);
const username = process.env.WEB_AUTH_USERNAME ?? "";

const auth = new WebAuth({ password, username });
const server = new WebServer({
port: PORT,
host: HOST,
enabled: true,
auth,
});

await server.start();

const url = server.getUrl();
const config = auth.getConfig();
const enabledTag = config.enabled ? "ENABLED" : "DISABLED";

console.log("============================================================");
console.log(" OpenCode Memory Explorer — auth test harness");
console.log("============================================================");
console.log(` URL : ${url}`);
console.log(` Auth : ${enabledTag}`);
if (config.enabled) {
console.log(` Username : ${config.username}`);
console.log(` Password : (see WEB_AUTH_PASSWORD env var —`);
console.log(` supports literal, env://, file://)`);
}
console.log("------------------------------------------------------------");
console.log(" Try these in a fresh browser window:");
console.log(` 1. Open ${url} → should pop the Basic Auth dialog`);
console.log(` 2. Cancel → 401 page → auth failed`);
console.log(` 3. Wrong password → 401 page → auth failed`);
console.log(` 4. Right password → app loads → auth ok`);
console.log(" 5. /api/health includes authEnabled flag for the warning banner");
console.log("------------------------------------------------------------");
console.log(" Press Ctrl-C to stop.");

process.on("SIGINT", async () => {
console.log("\nStopping…");
await server.stop();
process.exit(0);
});
22 changes: 22 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ interface OpenCodeMemConfig {
webServerEnabled?: boolean;
webServerPort?: number;
webServerHost?: string;
webServerAuthPassword?: string;
webServerAuthUsername?: string;
maxVectorsPerShard?: number;
autoCleanupEnabled?: boolean;
autoCleanupRetentionDays?: number;
Expand Down Expand Up @@ -109,6 +111,8 @@ const DEFAULTS: Required<
| "autoCaptureLanguage"
| "userEmailOverride"
| "userNameOverride"
| "webServerAuthPassword"
| "webServerAuthUsername"
>
> & {
embeddingApiUrl?: string;
Expand All @@ -125,6 +129,8 @@ const DEFAULTS: Required<
autoCaptureLanguage?: string;
userEmailOverride?: string;
userNameOverride?: string;
webServerAuthPassword?: string;
webServerAuthUsername?: string;
memory?: {
defaultScope?: "project" | "all-projects";
};
Expand All @@ -146,6 +152,8 @@ const DEFAULTS: Required<
webServerEnabled: true,
webServerPort: 4747,
webServerHost: "127.0.0.1",
webServerAuthPassword: undefined,
webServerAuthUsername: undefined,
maxVectorsPerShard: 50000,
autoCleanupEnabled: true,
autoCleanupRetentionDays: 30,
Expand Down Expand Up @@ -255,6 +263,18 @@ const CONFIG_TEMPLATE = `{

// Host address for web UI (use 127.0.0.1 for local only, 0.0.0.0 for network access)
"webServerHost": "127.0.0.1",

// HTTP Basic Auth for the web UI (recommended whenever webServerHost != 127.0.0.1).
// Leave webServerAuthPassword unset to keep the UI open (the previous default).
// When set, the server demands HTTP Basic Auth credentials on every request.
// The browser's native Basic Auth dialog handles the prompt; closing the
// browser discards the cached credentials, so reopening requires signing in again.
// Accepts the same secret formats as memoryApiKey:
// "literal-value" direct plaintext
// "env://SOME_ENV_VAR" resolved from an environment variable
// "file:///path/to/secret" read from a file (chmod 600 recommended)
// "webServerAuthPassword": "",
// "webServerAuthUsername": "",

// ============================================
// Database Settings
Expand Down Expand Up @@ -588,6 +608,8 @@ function buildConfig(fileConfig: OpenCodeMemConfig) {
webServerEnabled: fileConfig.webServerEnabled ?? DEFAULTS.webServerEnabled,
webServerPort: fileConfig.webServerPort ?? DEFAULTS.webServerPort,
webServerHost: fileConfig.webServerHost ?? DEFAULTS.webServerHost,
webServerAuthPassword: resolveSecretValue(fileConfig.webServerAuthPassword),
webServerAuthUsername: fileConfig.webServerAuthUsername,
maxVectorsPerShard: fileConfig.maxVectorsPerShard ?? DEFAULTS.maxVectorsPerShard,
autoCleanupEnabled: fileConfig.autoCleanupEnabled ?? DEFAULTS.autoCleanupEnabled,
autoCleanupRetentionDays:
Expand Down
10 changes: 9 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { performAutoCapture } from "./services/auto-capture.js";
import { performUserProfileLearning } from "./services/user-memory-learning.js";
import { userPromptManager } from "./services/user-prompt/user-prompt-manager.js";
import { startWebServer, WebServer } from "./services/web-server.js";
import { WebAuth } from "./services/web-auth.js";

import { isConfigured, CONFIG, initConfig } from "./config.js";
import { log } from "./services/logger.js";
Expand Down Expand Up @@ -108,10 +109,15 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
})();

if (CONFIG.webServerEnabled) {
const webAuth = new WebAuth({
password: CONFIG.webServerAuthPassword,
username: CONFIG.webServerAuthUsername,
});
startWebServer({
port: CONFIG.webServerPort,
host: CONFIG.webServerHost,
enabled: CONFIG.webServerEnabled,
auth: webAuth,
})
.then((server) => {
webServer = server;
Expand All @@ -138,7 +144,9 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
.showToast({
body: {
title: "Memory Explorer",
message: `Web UI started at ${url}`,
message: webAuth.isEnabled()
? `Web UI started at ${url} (auth required)`
: `Web UI started at ${url}`,
variant: "success",
duration: 5000,
},
Expand Down
37 changes: 27 additions & 10 deletions src/services/cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,39 @@ const ALLOWED_LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
const CORS_ALLOWED_METHODS = "GET, POST, PUT, DELETE, OPTIONS";
const CORS_ALLOWED_HEADERS = "Content-Type";

export function isAllowedBrowserOrigin(origin: string | null): boolean {
export interface CorsAllowOptions {
/**
* Whether HTTP Basic Auth is enforced by the web server. When true, the
* CORS gate opens up beyond loopback origins — the auth challenge is what
* actually protects memory data, so locking CORS down to loopback is
* unnecessary once Basic Auth is on.
*/
httpAuthEnabled?: boolean;
}

export function isAllowedBrowserOrigin(
origin: string | null,
options: CorsAllowOptions = {}
): boolean {
if (!origin) return true;

try {
const url = new URL(origin);
return (
(url.protocol === "http:" || url.protocol === "https:") &&
ALLOWED_LOOPBACK_HOSTS.has(url.hostname)
);
if (url.protocol !== "http:" && url.protocol !== "https:") return false;

if (ALLOWED_LOOPBACK_HOSTS.has(url.hostname)) return true;

return options.httpAuthEnabled === true;
} catch {
return false;
}
}

function corsHeaders(origin: string | null): Record<string, string> {
if (!origin || !isAllowedBrowserOrigin(origin)) return {};
function corsHeaders(
origin: string | null,
options: CorsAllowOptions = {}
): Record<string, string> {
if (!origin || !isAllowedBrowserOrigin(origin, options)) return {};

return {
"Access-Control-Allow-Origin": origin,
Expand All @@ -27,17 +44,17 @@ function corsHeaders(origin: string | null): Record<string, string> {
};
}

export function corsPreflightResponse(req: Request): Response {
export function corsPreflightResponse(req: Request, options: CorsAllowOptions = {}): Response {
const origin = req.headers.get("Origin");

if (!isAllowedBrowserOrigin(origin)) {
if (!isAllowedBrowserOrigin(origin, options)) {
return disallowedCorsResponse();
}

return new Response(null, {
status: 204,
headers: {
...corsHeaders(origin),
...corsHeaders(origin, options),
"Access-Control-Max-Age": "600",
},
});
Expand Down
136 changes: 136 additions & 0 deletions src/services/web-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { timingSafeEqual } from "node:crypto";
import { userInfo } from "node:os";

export const WEB_AUTH_REALM = "OpenCode Memory Explorer";

export interface WebAuthOptions {
/**
* Plain-text password used for HTTP Basic Auth. Empty / undefined disables
* auth entirely (the previous default). The caller is responsible for
* resolving `env://` / `file://` shortcuts via the shared
* `resolveSecretValue` helper before passing the value in.
*/
password?: string;
/**
* Plain-text username. Defaults to `os.userInfo().username` so that, when
* the server is bound to a LAN-reachable interface, the obvious choice
* is the OS account that launched opencode.
*/
username?: string;
}

export interface WebAuthConfig {
enabled: boolean;
username: string;
}

export interface AuthCheckResult {
ok: boolean;
response?: Response;
}

export class WebAuth {
private readonly enabled: boolean;
private readonly username: string;
private readonly expectedUsername: Buffer;
private readonly expectedPassword: Buffer;

constructor(options: WebAuthOptions = {}) {
const password = (options.password ?? "").trim();
this.enabled = password.length > 0;
const explicitUsername = (options.username ?? "").trim();
this.username = explicitUsername || safeOsUsername();
this.expectedUsername = Buffer.from(this.username, "utf8");
this.expectedPassword = Buffer.from(password, "utf8");
}

getConfig(): WebAuthConfig {
return { enabled: this.enabled, username: this.username };
}

isEnabled(): boolean {
return this.enabled;
}

/**
* Validate the HTTP Basic Auth credentials on the incoming request.
*
* Returns `{ ok: true }` when auth is disabled, when the request is for a
* path the user-facing browser must reach without credentials (the health
* probe), or when the supplied `Authorization: Basic …` header matches the
* configured username/password.
*
* Otherwise returns a fully-formed 401 Response carrying the
* `WWW-Authenticate` challenge so browsers pop their native login dialog.
*/
check(req: Request, path: string): AuthCheckResult {
if (!this.enabled) return { ok: true };

if (path === "/api/health") return { ok: true };

const header = req.headers.get("Authorization");
if (header) {
const decoded = decodeBasicAuth(header);
if (
decoded &&
constantTimeEquals(decoded.username, this.expectedUsername) &&
constantTimeEquals(decoded.password, this.expectedPassword)
) {
return { ok: true };
}
}

return { ok: false, response: this.challenge() };
}

challenge(): Response {
return new Response("Authentication required", {
status: 401,
headers: {
"Content-Type": "text/plain; charset=utf-8",
"WWW-Authenticate": `Basic realm="${WEB_AUTH_REALM}", charset="UTF-8"`,
"Cache-Control": "no-store",
},
});
}
}

function safeOsUsername(): string {
try {
const info = userInfo();
if (info.username && info.username.length > 0) return info.username;
} catch {
// userInfo can throw on some sandboxes; fall through to env fallbacks.
}
if (process.env.USER && process.env.USER.length > 0) return process.env.USER;
if (process.env.USERNAME && process.env.USERNAME.length > 0) return process.env.USERNAME;
return "user";
}

function constantTimeEquals(provided: string, expected: Buffer): boolean {
const providedBuf = Buffer.from(provided ?? "", "utf8");
if (providedBuf.length !== expected.length) {
// Run a dummy compare so the call duration stays independent of length.
timingSafeEqual(expected, expected);
return false;
}
return timingSafeEqual(providedBuf, expected);
}

function decodeBasicAuth(header: string): { username: string; password: string } | null {
if (!header.toLowerCase().startsWith("basic ")) return null;
const encoded = header.slice(6).trim();
if (!encoded) return null;
let decoded: string;
try {
decoded = Buffer.from(encoded, "base64").toString("utf8");
} catch {
return null;
}
const colon = decoded.indexOf(":");
if (colon === -1) return null;
return {
username: decoded.slice(0, colon),
password: decoded.slice(colon + 1),
};
}
Loading