diff --git a/src/cli/workspace-cli.ts b/src/cli/workspace-cli.ts index 5e0c18bcb..42cf89ec2 100644 --- a/src/cli/workspace-cli.ts +++ b/src/cli/workspace-cli.ts @@ -15,6 +15,7 @@ import { isRcloneInstalled, ensureRcloneInstalled, isRcloneConfigured, + ensureRcloneConfigFromConfig, resolveSyncConfig, runBisync, runSync, @@ -115,6 +116,9 @@ export function registerWorkspaceCli(program: Command): void { const resolved = resolveSyncConfig(syncConfig, workspaceDir, stateDir); + // Auto-generate rclone config from moltbot.json if credentials present + ensureRcloneConfigFromConfig(syncConfig, resolved.configPath, resolved.remoteName); + // Check config if (!isRcloneConfigured(resolved.configPath, resolved.remoteName)) { console.error( @@ -234,12 +238,16 @@ export function registerWorkspaceCli(program: Command): void { } console.log(colorize(rich, theme.success, "✓ rclone installed")); + // Auto-generate rclone config from moltbot.json if credentials present + ensureRcloneConfigFromConfig(syncConfig, resolved.configPath, resolved.remoteName); + // Check config const configured = isRcloneConfigured(resolved.configPath, resolved.remoteName); if (!configured) { console.log(colorize(rich, theme.error, "✗ rclone not configured")); console.log(""); console.log("Run: moltbot workspace authorize"); + console.log("Or add dropbox.token to workspace.sync config"); return; } console.log(colorize(rich, theme.success, "✓ rclone configured")); @@ -669,6 +677,9 @@ export function registerWorkspaceCli(program: Command): void { const resolved = resolveSyncConfig(syncConfig, workspaceDir, stateDir); + // Auto-generate rclone config from moltbot.json if credentials present + ensureRcloneConfigFromConfig(syncConfig, resolved.configPath, resolved.remoteName); + if (!isRcloneConfigured(resolved.configPath, resolved.remoteName)) { console.error(colorize(rich, theme.error, "rclone not configured.")); console.error("Run: moltbot workspace authorize"); diff --git a/src/config/types.workspace.ts b/src/config/types.workspace.ts index 212e394cd..0b486ce3a 100644 --- a/src/config/types.workspace.ts +++ b/src/config/types.workspace.ts @@ -101,10 +101,12 @@ export type WorkspaceSyncConfig = { dropbox?: { /** Use app folder access (more secure, limited to Apps//). */ appFolder?: boolean; - /** Dropbox app key (for app folder access). */ + /** Dropbox app key / client_id. */ appKey?: string; - /** Dropbox app secret (for app folder access). */ + /** Dropbox app secret / client_secret. */ appSecret?: string; + /** OAuth token JSON (prefer env var ${DROPBOX_TOKEN}). */ + token?: string; }; }; diff --git a/src/gateway/workspace-sync-manager.ts b/src/gateway/workspace-sync-manager.ts index fdabde206..9607956f1 100644 --- a/src/gateway/workspace-sync-manager.ts +++ b/src/gateway/workspace-sync-manager.ts @@ -10,6 +10,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { isRcloneInstalled, isRcloneConfigured, + ensureRcloneConfigFromConfig, resolveSyncConfig, runBisync, } from "../infra/rclone.js"; @@ -64,6 +65,9 @@ async function runSync(): Promise { const workspaceDir = resolveAgentWorkspaceDir(currentConfig, agentId); const resolved = resolveSyncConfig(syncConfig, workspaceDir); + // Auto-generate rclone config from moltbot.json if credentials present + ensureRcloneConfigFromConfig(syncConfig, resolved.configPath, resolved.remoteName); + // Check if configured if (!isRcloneConfigured(resolved.configPath, resolved.remoteName)) { logger.warn(`[workspace-sync] rclone not configured for "${resolved.remoteName}", skipping`); diff --git a/src/hooks/bundled/workspace-sync/handler.test.ts b/src/hooks/bundled/workspace-sync/handler.test.ts index dd81ff59a..6248c4d8e 100644 --- a/src/hooks/bundled/workspace-sync/handler.test.ts +++ b/src/hooks/bundled/workspace-sync/handler.test.ts @@ -21,6 +21,7 @@ vi.mock("../../../logging/subsystem.js", () => ({ vi.mock("../../../infra/rclone.js", () => ({ isRcloneInstalled: vi.fn(), isRcloneConfigured: vi.fn(), + ensureRcloneConfigFromConfig: vi.fn(), resolveSyncConfig: vi.fn(), runBisync: vi.fn(), })); diff --git a/src/hooks/bundled/workspace-sync/handler.ts b/src/hooks/bundled/workspace-sync/handler.ts index fc492ed0d..30bdfe734 100644 --- a/src/hooks/bundled/workspace-sync/handler.ts +++ b/src/hooks/bundled/workspace-sync/handler.ts @@ -13,6 +13,7 @@ import type { HookHandler } from "../../hooks.js"; import { isRcloneInstalled, isRcloneConfigured, + ensureRcloneConfigFromConfig, resolveSyncConfig, runBisync, } from "../../../infra/rclone.js"; @@ -75,6 +76,9 @@ const workspaceSyncHandler: HookHandler = async (event) => { const resolved = resolveSyncConfig(syncConfig, workspaceDir, stateDir); + // Auto-generate rclone config from moltbot.json if credentials present + ensureRcloneConfigFromConfig(syncConfig, resolved.configPath, resolved.remoteName); + // Check if rclone is configured if (!isRcloneConfigured(resolved.configPath, resolved.remoteName)) { log.warn(`rclone not configured for remote "${resolved.remoteName}", skipping sync`); diff --git a/src/infra/rclone.test.ts b/src/infra/rclone.test.ts index 2f1c960e4..f1ecc6266 100644 --- a/src/infra/rclone.test.ts +++ b/src/infra/rclone.test.ts @@ -1,7 +1,14 @@ import path from "node:path"; -import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import { describe, expect, it, beforeEach, afterEach } from "vitest"; -import { resolveSyncConfig, generateRcloneConfig, isRcloneConfigured } from "./rclone.js"; +import { + resolveSyncConfig, + generateRcloneConfig, + isRcloneConfigured, + ensureRcloneConfigFromConfig, +} from "./rclone.js"; describe("rclone helpers", () => { describe("resolveSyncConfig", () => { @@ -155,4 +162,116 @@ describe("rclone helpers", () => { expect(result).toBe(false); }); }); + + describe("ensureRcloneConfigFromConfig", () => { + let tempDir: string; + let configPath: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "rclone-test-")); + configPath = path.join(tempDir, "rclone.conf"); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("returns false when provider is off", () => { + const result = ensureRcloneConfigFromConfig({ provider: "off" }, configPath, "cloud"); + expect(result).toBe(false); + expect(fs.existsSync(configPath)).toBe(false); + }); + + it("returns false when provider is undefined", () => { + const result = ensureRcloneConfigFromConfig(undefined, configPath, "cloud"); + expect(result).toBe(false); + }); + + it("returns false when dropbox has no token", () => { + const result = ensureRcloneConfigFromConfig( + { provider: "dropbox", dropbox: { appKey: "key", appSecret: "secret" } }, + configPath, + "cloud", + ); + expect(result).toBe(false); + expect(fs.existsSync(configPath)).toBe(false); + }); + + it("generates config when dropbox has token", () => { + const result = ensureRcloneConfigFromConfig( + { + provider: "dropbox", + dropbox: { + token: '{"access_token":"test123"}', + appKey: "mykey", + appSecret: "mysecret", + }, + }, + configPath, + "cloud", + ); + + expect(result).toBe(true); + expect(fs.existsSync(configPath)).toBe(true); + + const content = fs.readFileSync(configPath, "utf-8"); + expect(content).toContain("[cloud]"); + expect(content).toContain("type = dropbox"); + expect(content).toContain('token = {"access_token":"test123"}'); + expect(content).toContain("client_id = mykey"); + expect(content).toContain("client_secret = mysecret"); + }); + + it("returns true without regenerating when config already exists", () => { + // Create existing config + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, "[cloud]\ntype = dropbox\ntoken = old"); + + const result = ensureRcloneConfigFromConfig( + { provider: "dropbox", dropbox: { token: '{"new":"token"}' } }, + configPath, + "cloud", + ); + + expect(result).toBe(true); + // Should NOT overwrite existing config + const content = fs.readFileSync(configPath, "utf-8"); + expect(content).toContain("token = old"); + }); + + it("returns false when s3 has no credentials", () => { + const result = ensureRcloneConfigFromConfig( + { provider: "s3", s3: { endpoint: "https://example.com" } }, + configPath, + "cloud", + ); + expect(result).toBe(false); + }); + + it("generates config when s3 has credentials", () => { + const result = ensureRcloneConfigFromConfig( + { + provider: "s3", + s3: { + endpoint: "https://r2.example.com", + accessKeyId: "AKID123", + secretAccessKey: "SECRET456", + region: "auto", + }, + }, + configPath, + "r2", + ); + + expect(result).toBe(true); + expect(fs.existsSync(configPath)).toBe(true); + + const content = fs.readFileSync(configPath, "utf-8"); + expect(content).toContain("[r2]"); + expect(content).toContain("type = s3"); + expect(content).toContain("endpoint = https://r2.example.com"); + expect(content).toContain("access_key_id = AKID123"); + expect(content).toContain("secret_access_key = SECRET456"); + }); + }); }); diff --git a/src/infra/rclone.ts b/src/infra/rclone.ts index e667bd02f..bfa493516 100644 --- a/src/infra/rclone.ts +++ b/src/infra/rclone.ts @@ -282,6 +282,67 @@ export function isRcloneConfigured(configPath: string, remoteName: string): bool } } +/** + * Ensure rclone config exists, auto-generating from moltbot.json config if credentials are present. + * This allows users to configure sync entirely via moltbot.json + env vars without manual rclone setup. + * + * @returns true if config exists or was generated, false if credentials are missing + */ +export function ensureRcloneConfigFromConfig( + syncConfig: WorkspaceSyncConfig | undefined, + configPath: string, + remoteName: string, +): boolean { + // If config already exists with this remote, we're good + if (isRcloneConfigured(configPath, remoteName)) { + return true; + } + + if (!syncConfig?.provider || syncConfig.provider === "off") { + return false; + } + + // For Dropbox: need token (appKey/appSecret optional but recommended) + if (syncConfig.provider === "dropbox") { + const token = syncConfig.dropbox?.token; + if (!token) { + return false; + } + + logVerbose(`[rclone] Auto-generating config for ${remoteName} from moltbot.json credentials`); + + const configContent = generateRcloneConfig(syncConfig.provider, remoteName, token, { + dropbox: { + appKey: syncConfig.dropbox?.appKey, + appSecret: syncConfig.dropbox?.appSecret, + }, + }); + + writeRcloneConfig(configPath, configContent); + return true; + } + + // For S3: need accessKeyId and secretAccessKey + if (syncConfig.provider === "s3") { + const { accessKeyId, secretAccessKey, endpoint, bucket, region } = syncConfig.s3 ?? {}; + if (!accessKeyId || !secretAccessKey) { + return false; + } + + logVerbose(`[rclone] Auto-generating config for ${remoteName} from moltbot.json credentials`); + + const configContent = generateRcloneConfig(syncConfig.provider, remoteName, "", { + s3: { endpoint, bucket, region, accessKeyId, secretAccessKey }, + }); + + writeRcloneConfig(configPath, configContent); + return true; + } + + // Other providers require manual rclone config + return false; +} + /** * Run rclone authorize command (returns the token). * This must be run on a machine with a browser.