workspace-sync: add config-driven rclone setup with dropbox.token

This commit is contained in:
Ash Brener 2026-01-28 12:10:00 +02:00
parent 3e9339c77f
commit ccbde17b9d
7 changed files with 206 additions and 4 deletions

View File

@ -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");

View File

@ -101,10 +101,12 @@ export type WorkspaceSyncConfig = {
dropbox?: {
/** Use app folder access (more secure, limited to Apps/<app-name>/). */
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;
};
};

View File

@ -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<void> {
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`);

View File

@ -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(),
}));

View File

@ -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`);

View File

@ -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");
});
});
});

View File

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