Skip to content
Draft
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
24 changes: 17 additions & 7 deletions src/client/OpenCodeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,26 +33,31 @@ export class OpenCodeClient {
private apiBaseUrl: string;
private uiBaseUrl: string;
private projectDirectory: string;
private password: string | null = null;
private trackedSessionId: string | null = null;
private lastPart: OpenCodePart | null = null;

constructor(apiBaseUrl: string, uiBaseUrl: string, projectDirectory: string) {
constructor(apiBaseUrl: string, uiBaseUrl: string, projectDirectory: string, password?: string) {
this.apiBaseUrl = this.normalizeBaseUrl(apiBaseUrl);
this.uiBaseUrl = this.normalizeBaseUrl(uiBaseUrl);
this.projectDirectory = projectDirectory;
this.password = password ?? null;
}

updateBaseUrl(apiBaseUrl: string, uiBaseUrl: string, projectDirectory: string): void {
updateBaseUrl(apiBaseUrl: string, uiBaseUrl: string, projectDirectory: string, password?: string): void {
const nextApiUrl = this.normalizeBaseUrl(apiBaseUrl);
const nextUiUrl = this.normalizeBaseUrl(uiBaseUrl);
const nextPassword = password ?? null;
if (
nextApiUrl !== this.apiBaseUrl ||
nextUiUrl !== this.uiBaseUrl ||
projectDirectory !== this.projectDirectory
projectDirectory !== this.projectDirectory ||
nextPassword !== this.password
) {
this.apiBaseUrl = nextApiUrl;
this.uiBaseUrl = nextUiUrl;
this.projectDirectory = projectDirectory;
this.password = nextPassword;
this.resetTracking();
}
}
Expand Down Expand Up @@ -163,12 +168,17 @@ export class OpenCodeClient {
private async request<T>(method: string, path: string, body?: unknown): Promise<OpenCodeResponse<T>> {
try {
const url = `${this.apiBaseUrl}${path}`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-opencode-directory": this.projectDirectory,
};
if (this.password) {
headers["Authorization"] = `Basic ${btoa(`opencode:${this.password}`)}`;
}

const response = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
"x-opencode-directory": this.projectDirectory,
},
headers,
body: body ? JSON.stringify(body) : undefined,
});

Expand Down
17 changes: 13 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { registerOpenCodeIcons, OPENCODE_ICON_NAME } from "./icons";
import { OpenCodeClient } from "./client/OpenCodeClient";
import { ContextManager } from "./context/ContextManager";
import { ExecutableResolver } from "./server/ExecutableResolver";
import { PasswordManager } from "./security/PasswordManager";

export default class OpenCodePlugin extends Plugin {
settings: OpenCodeSettings = DEFAULT_SETTINGS;
Expand All @@ -18,6 +19,7 @@ export default class OpenCodePlugin extends Plugin {
private viewManager: ViewManager;
private cachedIframeUrl: string | null = null;
private lastBaseUrl: string | null = null;
private password: string;

async onload(): Promise<void> {
console.log("Loading OpenCode plugin");
Expand All @@ -30,8 +32,9 @@ export default class OpenCodePlugin extends Plugin {
await this.attemptAutodetect();

const projectDirectory = this.getProjectDirectory();
this.password = PasswordManager.getOrCreatePassword(this.app);

this.processManager = new ServerManager(this.settings, projectDirectory);
this.processManager = new ServerManager(this.settings, projectDirectory, this.password);
this.processManager.on("stateChange", (state: ServerState) => {
this.notifyStateChange(state);
});
Expand All @@ -50,7 +53,8 @@ export default class OpenCodePlugin extends Plugin {
this.openCodeClient = new OpenCodeClient(
this.getApiBaseUrl(),
this.getServerUrl(),
projectDirectory
projectDirectory,
this.password
);
this.lastBaseUrl = this.getServerUrl();

Expand Down Expand Up @@ -92,7 +96,12 @@ export default class OpenCodePlugin extends Plugin {
this,
this.settings,
this.processManager,
() => this.saveSettings()
() => this.saveSettings(),
(newPassword: string) => {
this.password = newPassword;
this.processManager.setPassword(newPassword);
this.refreshClientState();
}
));

this.addRibbonIcon(OPENCODE_ICON_NAME, "OpenCode", () => {
Expand Down Expand Up @@ -255,7 +264,7 @@ export default class OpenCodePlugin extends Plugin {
const nextUiBaseUrl = this.getServerUrl();
const nextApiBaseUrl = this.getApiBaseUrl();
const projectDirectory = this.getProjectDirectory();
this.openCodeClient.updateBaseUrl(nextApiBaseUrl, nextUiBaseUrl, projectDirectory);
this.openCodeClient.updateBaseUrl(nextApiBaseUrl, nextUiBaseUrl, projectDirectory, this.password);

if (this.lastBaseUrl && this.lastBaseUrl !== nextUiBaseUrl) {
this.cachedIframeUrl = null;
Expand Down
83 changes: 83 additions & 0 deletions src/security/PasswordManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { randomBytes } from "crypto";
import { App } from "obsidian";

const SECRET_KEY = "opencode-server-password";
const PASSWORD_BYTES = 24; // 24 bytes = 32 chars in base64url

/**
* SecretStorage interface for type safety.
* Obsidian's SecretStorage provides secure credential storage.
*/
interface SecretStorage {
getSecret(id: string): string | null;
setSecret(id: string, secret: string): void;
listSecrets(): string[];
}

/**
* Get SecretStorage from App instance.
* Uses type assertion as SecretStorage may not be in older type definitions.
*/
function getSecretStorage(app: App): SecretStorage {
return (app as unknown as { secretStorage: SecretStorage }).secretStorage;
}

/**
* Utility class for managing the server authentication password.
* Uses Obsidian's SecretStorage for secure persistence and
* Node.js crypto for cryptographically secure random generation.
*/
export class PasswordManager {
/**
* Generates a cryptographically secure random password.
* @returns 32-character base64url encoded string
*/
static generatePassword(): string {
return randomBytes(PASSWORD_BYTES).toString("base64url");
}

/**
* Loads the stored password from SecretStorage.
* @param app - Obsidian App instance
* @returns The stored password or null if not set
*/
static loadPassword(app: App): string | null {
return getSecretStorage(app).getSecret(SECRET_KEY);
}

/**
* Stores a password in SecretStorage.
* @param app - Obsidian App instance
* @param password - The password to store
*/
static storePassword(app: App, password: string): void {
getSecretStorage(app).setSecret(SECRET_KEY, password);
}

/**
* Gets the existing password or creates and stores a new one.
* @param app - Obsidian App instance
* @returns The password (existing or newly generated)
*/
static getOrCreatePassword(app: App): string {
const existing = this.loadPassword(app);
if (existing) {
return existing;
}

const password = this.generatePassword();
this.storePassword(app, password);
return password;
}

/**
* Regenerates and stores a new password, replacing any existing one.
* @param app - Obsidian App instance
* @returns The newly generated password
*/
static regeneratePassword(app: App): string {
const password = this.generatePassword();
this.storePassword(app, password);
return password;
}
}
32 changes: 27 additions & 5 deletions src/server/ServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,21 @@ export class ServerManager extends EventEmitter {
private settings: OpenCodeSettings;
private projectDirectory: string;
private processImpl: OpenCodeProcess;
private password: string | null = null;

constructor(settings: OpenCodeSettings, projectDirectory: string) {
constructor(settings: OpenCodeSettings, projectDirectory: string, password?: string) {
super();
this.settings = settings;
this.projectDirectory = projectDirectory;
this.password = password ?? null;
this.processImpl =
process.platform === "win32" ? new WindowsProcess() : new PosixProcess();
}

setPassword(password: string): void {
this.password = password;
}

updateSettings(settings: OpenCodeSettings): void {
this.settings = settings;
}
Expand Down Expand Up @@ -66,11 +72,17 @@ export class ServerManager extends EventEmitter {
let spawnOptions: SpawnOptions;

if (this.settings.useCustomCommand) {
// Custom command mode: use custom command directly with shell
executablePath = this.settings.customCommand;
executablePath = this.settings.customCommand.replace(
/\$OPENCODE_PASSWORD/g,
this.password ?? ""
);
spawnOptions = {
cwd: this.projectDirectory,
env: { ...process.env, NODE_USE_SYSTEM_CA: "1" },
env: {
...process.env,
NODE_USE_SYSTEM_CA: "1",
OPENCODE_SERVER_PASSWORD: this.password ?? "",
},
stdio: ["ignore", "pipe", "pipe"],
shell: true,
};
Expand All @@ -86,7 +98,11 @@ export class ServerManager extends EventEmitter {

spawnOptions = {
cwd: this.projectDirectory,
env: { ...process.env, NODE_USE_SYSTEM_CA: "1" },
env: {
...process.env,
NODE_USE_SYSTEM_CA: "1",
OPENCODE_SERVER_PASSWORD: this.password ?? "",
},
stdio: ["ignore", "pipe", "pipe"],
};
}
Expand Down Expand Up @@ -224,8 +240,14 @@ export class ServerManager extends EventEmitter {

private async checkServerHealth(): Promise<boolean> {
try {
const headers: Record<string, string> = {};
if (this.password) {
headers["Authorization"] = `Basic ${btoa(`opencode:${this.password}`)}`;
}

const response = await fetch(`${this.getUrl()}/global/health`, {
method: "GET",
headers,
signal: AbortSignal.timeout(2000),
});
return response.ok;
Expand Down
49 changes: 47 additions & 2 deletions src/settings/SettingsTab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { homedir } from "os";
import { OpenCodeSettings, ViewLocation } from "../types";
import { ServerManager } from "../server/ServerManager";
import { ExecutableResolver } from "../server/ExecutableResolver";
import { PasswordManager } from "../security/PasswordManager";

function expandTilde(path: string): string {
if (path === "~") {
Expand All @@ -23,7 +24,8 @@ export class OpenCodeSettingTab extends PluginSettingTab {
plugin: Plugin,
private settings: OpenCodeSettings,
private serverManager: ServerManager,
private onSettingsChange: () => Promise<void>
private onSettingsChange: () => Promise<void>,
private onPasswordRegenerate: (newPassword: string) => void
) {
super(app, plugin);
}
Expand Down Expand Up @@ -94,7 +96,7 @@ export class OpenCodeSettingTab extends PluginSettingTab {
.setDesc("Custom shell command to start OpenCode.")
.addTextArea((text) => {
text
.setPlaceholder("opencode serve --port 14096 --hostname 127.0.0.1 --cors app://obsidian.md")
.setPlaceholder("opencode serve --port 14096 --hostname 127.0.0.1 --cors app://obsidian.md\nUse $OPENCODE_PASSWORD to inject the server password")
.setValue(this.settings.customCommand)
.onChange(async (value) => {
this.settings.customCommand = value;
Expand Down Expand Up @@ -231,6 +233,11 @@ export class OpenCodeSettingTab extends PluginSettingTab {
})
);

containerEl.createEl("h3", { text: "Security" });

const securitySection = containerEl.createDiv({ cls: "opencode-security-section" });
this.renderSecuritySection(securitySection);

containerEl.createEl("h3", { text: "Server Status" });

const statusContainer = containerEl.createDiv({ cls: "opencode-settings-status" });
Expand Down Expand Up @@ -274,6 +281,44 @@ export class OpenCodeSettingTab extends PluginSettingTab {
await this.onSettingsChange();
}

private renderSecuritySection(container: HTMLElement): void {
container.empty();

const statusEl = container.createDiv({ cls: "opencode-security-status" });
statusEl.createSpan({ text: "Password: " });
statusEl.createSpan({
text: "[secured]",
cls: "opencode-security-badge",
});

const buttonContainer = container.createDiv({ cls: "opencode-security-buttons" });
const regenerateButton = buttonContainer.createEl("button", {
text: "Regenerate Password",
cls: "mod-warning",
});
regenerateButton.addEventListener("click", async () => {
const wasRunning = this.serverManager.getState() === "running";
if (wasRunning) {
await this.serverManager.stop();
}

const newPassword = PasswordManager.regeneratePassword(this.app);

this.onPasswordRegenerate(newPassword);

if (wasRunning) {
await this.serverManager.start();
}

this.renderSecuritySection(container);
});

container.createEl("p", {
text: "Regenerating password will restart the server if it's running.",
cls: "opencode-security-hint",
});
}

private renderServerStatus(container: HTMLElement): void {
container.empty();

Expand Down
Loading