mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(studio): back up configuration to the signed-in account
Adds an opt-in cloud backup under Settings > Account, shown only while
signed in. The kernel collects the user-level setup by category, seals
it locally (gzip, then XChaCha20-Poly1305 under an Argon2id key derived
from a passphrase the user sets), and uploads only the envelope to the
accounts service; the service never sees plaintext.
Categories: settings (providers, default model, interface look),
extensions (user skills, plugin references by source and version, MCP
servers), memory (user-level instruction files and global facts),
automation (global hooks, status line) and secrets (stored provider
keys, off by default). Without secrets, literal env/header values are
stripped with the plugin exporter's StripCredentials and URL userinfo
is dropped; the account session token never travels. Approval mode,
permissions and sandbox are deliberately not restorable.
Restore is preview-then-apply. The preview decrypts, diffs each item
against this machine, shows everything an item would write (endpoints,
env, headers, cwd, instruction file bodies) and holds the snapshot under
a single-use plan id. Apply recomputes consent against the machine at
apply time and refuses, writing nothing, unless each of these is named
in `consented`: hooks, the status line and MCP servers (code that runs
here); a provider that is new or changes endpoint, and a default model
pointing at one (conversations and keys go there); a key that replaces
one already stored; an instruction file that imports other files.
A snapshot is rejected outright when a key is not read by a provider it
carries, when a provider reads REASONIX_* or a host-steering variable
(proxy, TLS, loader, PATH), or when a unit's payload names another item.
Plugins are never installed by restore; they come back as sources for
the ordinary plugin install flow and its plan approval. File items are
written only under their named root, through validated relative paths
that may not cross a symlink; hidden files are not exported. Config
writes go through config.EditConfigFile, hooks through hook.Save, keys
through config.SetCredential.
Serve: GET/POST /backups, DELETE /backups/{id}, POST /backups/{id}/preview,
POST /backups/apply, gated by the account grant and a signed-in token,
with dotted refusal codes the frontend words in both languages.
This commit is contained in:
1 parent
093e5e1b3b
commit
b328f4de7f
36 files changed
+3501
-8
No files matched your search
@@ -150,6 +150,19 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "account.reload", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "account.sign-in", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "account.sign-out", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "backup.category", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "backup.label", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "backup.passphrase", kind: "view", target: "optional", proof: "interaction" },
|
||||
{ id: "backup.create", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "backup.restore", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "backup.delete", kind: "destructive", target: "entity", proof: "interaction" },
|
||||
{ id: "backup.preview", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "backup.cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "backup.pick", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "backup.allow", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "backup.apply", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "backup.install-plugin", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "backup.done", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "ask.answer", kind: "interaction", target: "none", proof: "authority-effect" },
|
||||
{ id: "ask.step", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "code.download", kind: "interaction", target: "none", proof: "interaction" },
|
||||
|
||||
@@ -15,6 +15,7 @@ import { EN_METRICS } from "./en_metrics";
|
||||
import { EN_STORAGE } from "./en_storage";
|
||||
import { EN_USAGE } from "./en_usage";
|
||||
import { EN_COMPOSER } from "./en_composer";
|
||||
import { EN_BACKUP } from "./en_backup";
|
||||
|
||||
export const EN: Record<string, string> = {
|
||||
"工作台": "Workbench",
|
||||
@@ -24,6 +25,7 @@ export const EN: Record<string, string> = {
|
||||
...EN_STORAGE,
|
||||
...EN_USAGE,
|
||||
...EN_COMPOSER,
|
||||
...EN_BACKUP,
|
||||
...EN_KERNEL,
|
||||
...EN_REMOTE,
|
||||
...EN_WINDOW,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// English for the cloud backup section under Settings > Account.
|
||||
export const EN_BACKUP: Record<string, string> = {
|
||||
"云备份": "Cloud backup",
|
||||
"把本机的模型、扩展、记忆等配置加密保存到账号,换机器或重装后按项恢复。":
|
||||
"Encrypt this machine's models, extensions, memory and other setup into your account, and restore it item by item on a new machine or after a reinstall.",
|
||||
"模型与界面设置": "Models and interface",
|
||||
"技能、插件与 MCP": "Skills, plugins and MCP",
|
||||
"长期记忆": "Long-term memory",
|
||||
"Hooks 与状态栏命令": "Hooks and status line command",
|
||||
"API 密钥": "API keys",
|
||||
"供应商、默认模型、预设与界面外观;不含审批模式、权限和沙箱":
|
||||
"Providers, default model, presets and appearance; approval mode, permissions and sandbox are never included",
|
||||
"插件只记录来源与版本,恢复时仍走插件安装确认":
|
||||
"Plugins are recorded by source and version only, and restoring one goes through the usual install confirmation",
|
||||
"用户级指令文件与全局记忆": "User-level instruction files and global memory",
|
||||
"会在本机运行的命令,恢复时逐项确认": "Commands that run on this machine; each one is confirmed on restore",
|
||||
"默认不备份。勾选后密钥会随备份一起加密上传": "Off by default. When ticked, keys are encrypted and uploaded with the backup",
|
||||
"备份在本机用你设置的口令加密后才上传,服务端看不到其中内容。忘记口令就无法恢复。":
|
||||
"A backup is encrypted here with your passphrase before it is uploaded, so the service cannot read it. A forgotten passphrase cannot be recovered.",
|
||||
"备份内容": "What to back up",
|
||||
"备注(可选)": "Label (optional)",
|
||||
"例如:公司笔记本": "e.g. work laptop",
|
||||
"加密口令(至少 {n} 个字符)": "Passphrase (at least {n} characters)",
|
||||
"再输一次": "Repeat it",
|
||||
"两次输入的口令不一致": "The two passphrases differ",
|
||||
"正在加密上传…": "Encrypting and uploading…",
|
||||
"备份到账号": "Back up to account",
|
||||
"已用 {n}/{max} 份": "{n} of {max} used",
|
||||
"已备份。": "Backed up.",
|
||||
"已备份。{n} 个文件过大或无法读取,未包含在内。": "Backed up. {n} files were too large or unreadable and were left out.",
|
||||
"账号里还没有备份。": "No backups in this account yet.",
|
||||
"未命名备份": "Untitled backup",
|
||||
"恢复…": "Restore…",
|
||||
"确认删除": "Confirm delete",
|
||||
"正在读取备份…": "Loading backups…",
|
||||
"本机没有": "Not on this machine",
|
||||
"与本机不同": "Differs from this machine",
|
||||
"与本机相同": "Same as this machine",
|
||||
"需安装": "Needs install",
|
||||
"我已看过这条命令,允许它在本机运行": "I have read this command and allow it to run on this machine",
|
||||
"我确认允许把对话和本机保存的密钥发往这个地址": "I allow conversations and the key stored here to be sent to this address",
|
||||
"我确认用备份里的密钥替换本机已保存的密钥": "I allow the key in the backup to replace the one stored here",
|
||||
"我已读过这份指令文件,允许它引用其中列出的文件": "I have read this instruction file and allow the files it imports",
|
||||
"已恢复 {n} 项。": "Restored {n} items.",
|
||||
"配置已写入,但当前会话未能重新加载:{e}": "The setup was written, but this session could not reload it: {e}",
|
||||
"以下插件需要走正常的安装确认:": "These plugins go through the usual install confirmation:",
|
||||
"安装…": "Install…",
|
||||
"输入备份「{name}」的加密口令,先预览它和本机的差异,不会立即写入。":
|
||||
"Enter the passphrase for “{name}” to preview how it differs from this machine. Nothing is written yet.",
|
||||
"加密口令": "Passphrase",
|
||||
"正在解密…": "Decrypting…",
|
||||
"预览差异": "Preview differences",
|
||||
"这份备份来自 {p}": "This backup comes from {p}",
|
||||
"命令和路径在另一种系统上可能无法运行,这类项目默认不勾选。":
|
||||
"Commands and paths may not work on another system, so those items start unticked.",
|
||||
"本机现为:": "Here now: ",
|
||||
"本机不存在:{p}": "Missing here: {p}",
|
||||
"来自另一种系统,可能无法运行": "From another system; it may not run here",
|
||||
"备份时有 {n} 个文件过大或无法读取,未包含在内。": "{n} files were too large or unreadable at backup time and are not included.",
|
||||
"正在恢复…": "Restoring…",
|
||||
"恢复所选 {n} 项": "Restore {n} selected",
|
||||
"还有 {n} 项需要逐项确认": "{n} items still need confirmation",
|
||||
};
|
||||
@@ -185,6 +185,22 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"没有已配置的模型匹配「{model}」": "No configured model matches “{model}”",
|
||||
"这台服务器未开放 shell 设置": "This server does not open shell settings",
|
||||
"这台服务器未开放账号登录": "This server does not open account sign-in",
|
||||
"登录账号后才能使用云备份": "Sign in to use cloud backups",
|
||||
"暂时连不上备份服务,稍后再试:{detail}": "The backup service could not be reached; try again later: {detail}",
|
||||
"备注最多 {max} 个字": "A label can be at most {max} characters",
|
||||
"读取本机配置时出错:{detail}": "Reading this machine's setup failed: {detail}",
|
||||
"无法和本机配置比对:{detail}": "Could not compare with this machine's setup: {detail}",
|
||||
"加密口令至少 {min} 个字符": "The passphrase needs at least {min} characters",
|
||||
"至少选择一类要备份的内容": "Choose at least one kind of content to back up",
|
||||
"口令不对,或这份备份已损坏": "Wrong passphrase, or this backup is damaged",
|
||||
"这份备份来自更新的版本,请先升级 Studio": "This backup comes from a newer version; update Studio first",
|
||||
"这份备份的内容无法识别": "The contents of this backup could not be read",
|
||||
"备份超出了大小上限": "The backup is over the size limit",
|
||||
"预览已失效,请重新打开这份备份": "The preview has expired; open the backup again",
|
||||
"所选的项目不在这份备份里": "A selected item is not in this backup",
|
||||
"这份备份已不存在": "That backup no longer exists",
|
||||
"备份数量已达上限,请先删除一份旧备份": "The backup limit is reached; delete an older backup first",
|
||||
"有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复": "Some items run programs here or change where a key is sent; confirm each one before restoring",
|
||||
"这台服务器不支持切换工作区": "This server cannot switch workspaces",
|
||||
"不存在该预设": "There is no such preset",
|
||||
"本次拖入 {count} 个,最多允许 {limit} 个": "{count} dropped at once — the limit is {limit}",
|
||||
|
||||
@@ -80,6 +80,22 @@ const SAID: Record<string, string> = {
|
||||
"roles.model_unknown": "没有已配置的模型匹配「{model}」",
|
||||
"shell.editing_disabled": "这台服务器未开放 shell 设置",
|
||||
"account.signin_disabled": "这台服务器未开放账号登录",
|
||||
"backup.signed_out": "登录账号后才能使用云备份",
|
||||
"backup.cloud_unavailable": "暂时连不上备份服务,稍后再试:{detail}",
|
||||
"backup.label_too_long": "备注最多 {max} 个字",
|
||||
"backup.collect_failed": "读取本机配置时出错:{detail}",
|
||||
"backup.preview_failed": "无法和本机配置比对:{detail}",
|
||||
"backup.weak_passphrase": "加密口令至少 {min} 个字符",
|
||||
"backup.bad_categories": "至少选择一类要备份的内容",
|
||||
"backup.cannot_decrypt": "口令不对,或这份备份已损坏",
|
||||
"backup.unsupported_format": "这份备份来自更新的版本,请先升级 Studio",
|
||||
"backup.malformed": "这份备份的内容无法识别",
|
||||
"backup.too_large": "备份超出了大小上限",
|
||||
"backup.plan_expired": "预览已失效,请重新打开这份备份",
|
||||
"backup.unknown_item": "所选的项目不在这份备份里",
|
||||
"backup.not_found": "这份备份已不存在",
|
||||
"backup.limit_reached": "备份数量已达上限,请先删除一份旧备份",
|
||||
"backup.consent_required": "有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复",
|
||||
"workspace.changing_disabled": "这台服务器不支持切换工作区",
|
||||
"settings.unknown_preset": "不存在该预设",
|
||||
"drop.too_many_paths": "本次拖入 {count} 个,最多允许 {limit} 个",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Configuration backups kept in the signed-in account. The kernel seals each
|
||||
// one with the person's passphrase before it leaves; the service stores only
|
||||
// the envelope and the metadata listed here.
|
||||
export type BackupCategory = "settings" | "extensions" | "memory" | "automation" | "secrets";
|
||||
|
||||
export interface BackupCategoryInfo {
|
||||
id: BackupCategory;
|
||||
defaultOn: boolean;
|
||||
consent: boolean;
|
||||
}
|
||||
|
||||
export interface BackupEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
format: number;
|
||||
appVersion: string;
|
||||
platform: string;
|
||||
categories: BackupCategory[];
|
||||
ciphertextBytes: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface BackupCatalog {
|
||||
categories: BackupCategoryInfo[];
|
||||
backups: BackupEntry[];
|
||||
limits: { maxCount: number; maxBytes: number };
|
||||
minPassphrase: number;
|
||||
}
|
||||
|
||||
export interface BackupOmission {
|
||||
kind: string;
|
||||
name: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface BackupCreateRequest {
|
||||
label: string;
|
||||
categories: BackupCategory[];
|
||||
passphrase: string;
|
||||
appVersion?: string;
|
||||
}
|
||||
|
||||
export interface BackupCreated {
|
||||
backup: BackupEntry;
|
||||
omitted?: BackupOmission[];
|
||||
}
|
||||
|
||||
export interface BackupPathRef {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
// consent is set when restoring the item runs code on this machine, sends
|
||||
// conversations or a stored key somewhere new, replaces a stored key, or
|
||||
// imports other files into the standing instructions. The kernel refuses such an item unless it is
|
||||
// named again in consented, whatever the selection said.
|
||||
export interface BackupPlanItem {
|
||||
id: string;
|
||||
category: BackupCategory;
|
||||
kind: string;
|
||||
name: string;
|
||||
status: "new" | "changed" | "same" | "install";
|
||||
consent?: "executes" | "endpoint" | "replaces_secret" | "imports";
|
||||
summary?: string;
|
||||
previous?: string;
|
||||
paths?: BackupPathRef[];
|
||||
crossPlatform?: boolean;
|
||||
files?: number;
|
||||
details?: string[];
|
||||
content?: string;
|
||||
recommended: boolean;
|
||||
}
|
||||
|
||||
export interface BackupPlan {
|
||||
planId: string;
|
||||
createdAt: string;
|
||||
appVersion?: string;
|
||||
platform: string;
|
||||
samePlatform: boolean;
|
||||
categories: BackupCategory[];
|
||||
items: BackupPlanItem[];
|
||||
omitted?: BackupOmission[];
|
||||
}
|
||||
|
||||
export interface BackupPluginRef {
|
||||
name: string;
|
||||
source: string;
|
||||
version?: string;
|
||||
commit?: string;
|
||||
}
|
||||
|
||||
export interface BackupApplyResult {
|
||||
applied: string[];
|
||||
plugins?: BackupPluginRef[];
|
||||
failed?: { id: string; error: string }[];
|
||||
reloadError?: string;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { PlanAction } from "./session";
|
||||
import { HttpError } from "./port";
|
||||
import type { AccountState, AgentPort, ChangeDiff, Completion, CompletionItem, DeviceGrant, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, WorkspaceChanges, Attachment, DroppedRef, Queue, QueueItem, Queued, NotifyPrefs, TrayPrefs } from "./port";
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import { MockTheme } from "./mock_theme";
|
||||
import { MockBackup } from "./mock_backup";
|
||||
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
|
||||
import { MockExecutionHold, mockExecutionGraph } from "./mock_graph";
|
||||
import { mockStorage, mockStoragePlan } from "./mock_storage";
|
||||
@@ -11,7 +11,7 @@ import { mockUsage } from "./mock_usage";
|
||||
|
||||
|
||||
|
||||
export class MockPort extends MockTheme implements AgentPort {
|
||||
export class MockPort extends MockBackup implements AgentPort {
|
||||
private listeners = new Set<(ev: WireEvent) => void>();
|
||||
private log: WireEvent[] = [];
|
||||
// What the user has sent, so checkpoints() can mirror one per turn.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { MockTheme } from "./mock_theme";
|
||||
import type { BackupApplyResult, BackupCatalog, BackupCreated, BackupCreateRequest, BackupEntry, BackupPlan } from "./backup";
|
||||
|
||||
const CATEGORIES: BackupCatalog["categories"] = [
|
||||
{ id: "settings", defaultOn: true, consent: false },
|
||||
{ id: "extensions", defaultOn: true, consent: false },
|
||||
{ id: "memory", defaultOn: true, consent: false },
|
||||
{ id: "automation", defaultOn: true, consent: true },
|
||||
{ id: "secrets", defaultOn: false, consent: false },
|
||||
];
|
||||
|
||||
// The fixture keeps what it was handed in memory, so the settings section can
|
||||
// be driven end to end with no account service behind it.
|
||||
export class MockBackup extends MockTheme {
|
||||
private backupList: BackupEntry[] = [];
|
||||
|
||||
async backups(): Promise<BackupCatalog> {
|
||||
return { categories: CATEGORIES, backups: this.backupList, limits: { maxCount: 10, maxBytes: 4 << 20 }, minPassphrase: 10 };
|
||||
}
|
||||
|
||||
async createBackup(req: BackupCreateRequest): Promise<BackupCreated> {
|
||||
const backup: BackupEntry = {
|
||||
id: "b" + (this.backupList.length + 1), label: req.label, format: 1, appVersion: "demo",
|
||||
platform: "darwin/arm64", categories: req.categories, ciphertextBytes: 18432, createdAt: new Date().toISOString(),
|
||||
};
|
||||
this.backupList = [backup, ...this.backupList];
|
||||
return { backup };
|
||||
}
|
||||
|
||||
async deleteBackup(id: string): Promise<void> {
|
||||
this.backupList = this.backupList.filter((b) => b.id !== id);
|
||||
}
|
||||
|
||||
async previewBackup(): Promise<BackupPlan> {
|
||||
return {
|
||||
planId: "demo", createdAt: new Date().toISOString(), platform: "darwin/arm64", samePlatform: true,
|
||||
categories: ["settings", "automation"],
|
||||
items: [
|
||||
{ id: "provider:deepseek", category: "settings", kind: "provider", name: "deepseek", status: "same", summary: "openai https://api.deepseek.com", recommended: false },
|
||||
{ id: "hook:PreToolUse#1", category: "automation", kind: "hook", name: "PreToolUse#1", status: "new", consent: "executes", summary: "npx prettier --check .", details: ["event: PreToolUse", "match: edit_file"], recommended: false },
|
||||
{ id: "memory:docs/REASONIX.md", category: "memory", kind: "memory", name: "docs/REASONIX.md", status: "changed", summary: "REASONIX.md", content: "回答保持简洁。\n提交信息用英文。", recommended: true },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async applyBackup(_planId: string, items: string[]): Promise<BackupApplyResult> {
|
||||
return { applied: items };
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ export type { Attachment, DroppedRef };
|
||||
|
||||
import type { AccountState, AccountUser, DeviceGrant } from "./account";
|
||||
import type { Adjudications } from "./adjudication";
|
||||
import type { BackupApplyResult, BackupCatalog, BackupCreated, BackupCreateRequest, BackupPlan } from "./backup";
|
||||
export type * from "./backup";
|
||||
export type { AdjudicationEntry, AdjudicationState, Adjudications } from "./adjudication";
|
||||
import type { HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource } from "./hook";
|
||||
import type { CapabilityScope, McpCatalog, McpDraft, McpDraftServer, McpEntry, McpInstallResult, McpInstallScope, McpLoad, McpRisk, McpTool, ScopeLayer } from "./mcp";
|
||||
@@ -297,6 +299,12 @@ export interface AgentPort {
|
||||
accountLogin(): Promise<DeviceGrant>;
|
||||
accountPoll(deviceCode: string): Promise<{ status: "pending" | "complete"; slowDown?: boolean }>;
|
||||
accountLogout(): Promise<void>;
|
||||
// Backups exist only for a signed-in account; every call refuses otherwise.
|
||||
backups(): Promise<BackupCatalog>;
|
||||
createBackup(req: BackupCreateRequest): Promise<BackupCreated>;
|
||||
deleteBackup(id: string): Promise<void>;
|
||||
previewBackup(id: string, passphrase: string): Promise<BackupPlan>;
|
||||
applyBackup(planId: string, items: string[], consented: string[]): Promise<BackupApplyResult>;
|
||||
workspaces(): Promise<WorkspaceInfo>;
|
||||
// Rebuilds the whole runtime against another folder. The conversation does
|
||||
// not come along, so the caller has to reload the transcript afterwards.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PLAN_ACTIONS, type PlanAction } from "./session";
|
||||
import type { AccountState, AgentPort, Appearance, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
|
||||
import { HttpError, type Attachment, type ChangeDiff, type DroppedRef, type WorkspaceFile, type WorkspaceFiles, type WorkspaceChanges } from "./port";
|
||||
import { SseTheme } from "./sse_theme";
|
||||
import { SseBackup } from "./sse_backup";
|
||||
import type { StoragePlan, StorageState } from "./storage";
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import { host } from "./host";
|
||||
@@ -15,7 +15,7 @@ import { current } from "../i18n";
|
||||
// a load. The read is one small JSON body and answers from memory.
|
||||
const UPDATE_POLL_MS = 500;
|
||||
|
||||
export class SsePort extends SseTheme implements AgentPort {
|
||||
export class SsePort extends SseBackup implements AgentPort {
|
||||
status() {
|
||||
return this.get<SessionStatus>("/status");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { SseTheme } from "./sse_theme";
|
||||
import type { BackupApplyResult, BackupCatalog, BackupCreated, BackupCreateRequest, BackupPlan } from "./backup";
|
||||
|
||||
// Backups kept in the signed-in account. Every call is refused while signed
|
||||
// out, so a caller shows the section only when the account says signed in.
|
||||
export class SseBackup extends SseTheme {
|
||||
backups() {
|
||||
return this.get<BackupCatalog>("/backups");
|
||||
}
|
||||
createBackup(req: BackupCreateRequest) {
|
||||
return this.post0<BackupCreated>("/backups", req);
|
||||
}
|
||||
deleteBackup(id: string) {
|
||||
return this.del("/backups/" + encodeURIComponent(id));
|
||||
}
|
||||
previewBackup(id: string, passphrase: string) {
|
||||
return this.post0<BackupPlan>("/backups/" + encodeURIComponent(id) + "/preview", { passphrase });
|
||||
}
|
||||
applyBackup(planId: string, items: string[], consented: string[]) {
|
||||
return this.post0<BackupApplyResult>("/backups/apply", { planId, items, consented });
|
||||
}
|
||||
}
|
||||
@@ -3764,6 +3764,44 @@
|
||||
.acct-who .em { font: var(--w-reg) var(--fs-ui) var(--mono); color: var(--muted) }
|
||||
.acct-note { font: var(--w-reg) var(--fs-ui)/1.6 var(--ui); color: var(--muted); margin: 0 }
|
||||
.acct-note[data-err] { color: var(--err) }
|
||||
|
||||
.bk { display: flex; flex-direction: column; gap: 12px }
|
||||
.bk-cats { display: flex; flex-direction: column; border-top: 1px solid var(--hair) }
|
||||
.bk-cat { display: grid; grid-template-columns: auto 1fr; column-gap: 9px; row-gap: 2px; padding: 8px 0; border-bottom: 1px solid var(--hair); cursor: pointer }
|
||||
.bk-cat input { grid-row: span 2; margin-top: 3px }
|
||||
.bk-cat .nm { font: var(--w-med) var(--fs-ui) var(--ui); color: var(--text) }
|
||||
.bk-cat .why { font: var(--w-reg) var(--fs-meta)/1.55 var(--ui); color: var(--muted) }
|
||||
.bk-cat[data-warn] .why { color: var(--warn) }
|
||||
.bk-fields { display: flex; flex-wrap: wrap; gap: 10px }
|
||||
.bk-fields label { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 150px }
|
||||
.bk-fields label.full { flex-basis: 100% }
|
||||
.bk-fields span { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint) }
|
||||
.bk-fields input {
|
||||
padding: 6px 9px; border: 1px solid var(--border); border-radius: var(--r-xs);
|
||||
background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui) var(--ui);
|
||||
}
|
||||
.bk-fields input:focus { outline: 2px solid var(--focus); outline-offset: 1px }
|
||||
.bk-acts { display: flex; align-items: center; flex-wrap: wrap; gap: 8px }
|
||||
.bk-acts .act[data-primary] { border-color: var(--accent); background: var(--accent); color: var(--accent-fg) }
|
||||
.bk-acts .act[data-primary]:disabled { border-color: var(--border); background: transparent; color: var(--ghost) }
|
||||
.bk-list, .bk-plan { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--hair) }
|
||||
.bk-row { display: flex; align-items: center; gap: 8px; padding: 9px 0; border-bottom: 1px solid var(--hair) }
|
||||
.bk-row .who { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0 }
|
||||
.bk-row .nm { font: var(--w-med) var(--fs-ui) var(--ui); color: var(--text) }
|
||||
.bk-row .meta { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted); overflow-wrap: anywhere }
|
||||
.bk-restore { display: flex; flex-direction: column; gap: 10px; padding: 12px; border: 1px solid var(--border); border-radius: var(--r-sm) }
|
||||
.bk-item { display: flex; flex-direction: column; gap: 4px; padding: 8px 0; border-bottom: 1px solid var(--hair) }
|
||||
.bk-item .take { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; cursor: pointer }
|
||||
.bk-item .cat { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint) }
|
||||
.bk-item .nm { font: var(--w-med) var(--fs-ui) var(--mono); color: var(--text); overflow-wrap: anywhere }
|
||||
.bk-item .st { margin-left: auto; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted) }
|
||||
.bk-item[data-status="same"] .nm { color: var(--muted) }
|
||||
.bk-item .sum, .bk-item .prev code { font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--text); overflow-wrap: anywhere; padding-left: 22px }
|
||||
.bk-item .prev { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted); padding-left: 22px }
|
||||
.bk-item .prev code { padding-left: 4px }
|
||||
.bk-item .body { margin: 0 0 0 22px; max-height: 180px; overflow: auto; padding: 8px 10px; border-radius: var(--r-xs); background: var(--overlay); font: var(--w-reg) var(--fs-meta)/1.5 var(--mono); color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere }
|
||||
.bk-item .warn { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--warn); padding-left: 22px }
|
||||
.bk-item .allow { display: flex; align-items: center; gap: 7px; margin-left: 22px; padding: 6px 8px; border-radius: var(--r-xs); background: var(--overlay); font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--text); cursor: pointer }
|
||||
.acct-act { display: flex; align-items: center; gap: 11px; flex-wrap: wrap }
|
||||
.acct-code { display: flex; flex-direction: column; gap: 9px; align-items: flex-start }
|
||||
|
||||
|
||||
@@ -27,12 +27,14 @@ interface Props {
|
||||
// already known, so the box is skipped and the plan is read against what is
|
||||
// installed — a new version's new hook is the thing worth seeing.
|
||||
updating?: PluginPackage;
|
||||
// A source someone already chose, such as a plugin named in a restored backup.
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export function AddPlugin({ port, onClose, onInstalled, updating }: Props) {
|
||||
export function AddPlugin({ port, onClose, onInstalled, updating, source }: Props) {
|
||||
// Mounted only while open, so this component is the layer Escape closes.
|
||||
useEscape(true, onClose);
|
||||
const [text, setText] = useState(updating?.source ?? "");
|
||||
const [text, setText] = useState(updating?.source ?? source ?? "");
|
||||
const [plan, setPlan] = useState<PluginPlan | null>(null);
|
||||
const [done, setDone] = useState<PluginPlan | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { Backup } from "./Backup";
|
||||
import { BackupRestore } from "./BackupRestore";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { AgentPort, BackupEntry } from "../port/port";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const entry: BackupEntry = {
|
||||
id: "b1", label: "laptop", format: 1, appVersion: "2.20.5", platform: "darwin/arm64",
|
||||
categories: ["settings", "automation"], ciphertextBytes: 2048, createdAt: "2026-09-27T00:00:00Z",
|
||||
};
|
||||
|
||||
describe("cloud backup", () => {
|
||||
it("starts with API keys unticked and sends the ticked categories with the passphrase", async () => {
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
const create = vi.spyOn(port, "createBackup");
|
||||
render(<Backup port={port} />);
|
||||
|
||||
const secrets = await screen.findByRole("checkbox", { name: /API 密钥/ });
|
||||
expect((secrets as HTMLInputElement).checked).toBe(false);
|
||||
const [pass, again] = document.querySelectorAll<HTMLInputElement>('input[type="password"]');
|
||||
await userEvent.type(pass!, "a long passphrase");
|
||||
await userEvent.type(again!, "a long passphrase");
|
||||
await userEvent.click(screen.getByRole("button", { name: "备份到账号" }));
|
||||
|
||||
await waitFor(() => expect(create).toHaveBeenCalled());
|
||||
const req = create.mock.calls[0]![0];
|
||||
expect(req.categories).not.toContain("secrets");
|
||||
expect(req.categories).toContain("automation");
|
||||
expect(req.passphrase).toBe("a long passphrase");
|
||||
});
|
||||
|
||||
it("holds a command back until its own consent box is ticked, and sends that consent", async () => {
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
const apply = vi.spyOn(port, "applyBackup");
|
||||
render(<BackupRestore port={port} backup={entry} onClose={() => {}} />);
|
||||
|
||||
await userEvent.type(document.querySelector<HTMLInputElement>('input[type="password"]')!, "a long passphrase");
|
||||
await userEvent.click(screen.getByRole("button", { name: "预览差异" }));
|
||||
const hookRow = await screen.findByText("PreToolUse#1");
|
||||
expect(screen.getByText("npx prettier --check .")).toBeTruthy();
|
||||
|
||||
const take = hookRow.closest("li")!.querySelector<HTMLInputElement>('[data-action="backup.pick"]')!;
|
||||
expect(take.checked).toBe(false);
|
||||
await userEvent.click(take);
|
||||
const applyBtn = screen.getByRole<HTMLButtonElement>("button", { name: /恢复所选/ });
|
||||
expect(applyBtn.disabled).toBe(true);
|
||||
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: /允许它在本机运行/ }));
|
||||
expect(applyBtn.disabled).toBe(false);
|
||||
await userEvent.click(applyBtn);
|
||||
await waitFor(() => expect(apply).toHaveBeenCalledWith("demo", ["hook:PreToolUse#1", "memory:docs/REASONIX.md"], ["hook:PreToolUse#1"]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import type { AgentPort, BackupCatalog, BackupCategory, BackupEntry } from "../port/port";
|
||||
import { BackupRestore, CATEGORY_LABEL } from "./BackupRestore";
|
||||
|
||||
const CATEGORY_NOTE: Record<BackupCategory, string> = {
|
||||
settings: "供应商、默认模型、预设与界面外观;不含审批模式、权限和沙箱",
|
||||
extensions: "插件只记录来源与版本,恢复时仍走插件安装确认",
|
||||
memory: "用户级指令文件与全局记忆",
|
||||
automation: "会在本机运行的命令,恢复时逐项确认",
|
||||
secrets: "默认不备份。勾选后密钥会随备份一起加密上传",
|
||||
};
|
||||
|
||||
// Shown only to a signed-in account: a backup lives in the account, so there
|
||||
// is nothing to offer before one exists.
|
||||
export function Backup({ port }: { port: AgentPort }) {
|
||||
const [catalog, setCatalog] = useState<BackupCatalog | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [chosen, setChosen] = useState<Set<BackupCategory>>(new Set());
|
||||
const [label, setLabel] = useState("");
|
||||
const [pass, setPass] = useState("");
|
||||
const [again, setAgain] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const [dropping, setDropping] = useState("");
|
||||
const [restoring, setRestoring] = useState<BackupEntry | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const c = await port.backups();
|
||||
setCatalog(c);
|
||||
setChosen((prev) => (prev.size ? prev : new Set(c.categories.filter((x) => x.defaultOn).map((x) => x.id))));
|
||||
setError("");
|
||||
} catch (e) {
|
||||
setError(reason(e));
|
||||
}
|
||||
}, [port]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const toggle = (id: BackupCategory) =>
|
||||
setChosen((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const min = catalog?.minPassphrase ?? 10;
|
||||
const mismatch = again !== "" && pass !== again;
|
||||
const ready = chosen.size > 0 && pass.length >= min && pass === again && !busy;
|
||||
|
||||
const create = async () => {
|
||||
setBusy(true);
|
||||
setNote("");
|
||||
setError("");
|
||||
try {
|
||||
const order = (catalog?.categories ?? []).map((c) => c.id).filter((id) => chosen.has(id));
|
||||
const out = await port.createBackup({ label: label.trim(), categories: order, passphrase: pass });
|
||||
setPass("");
|
||||
setAgain("");
|
||||
setLabel("");
|
||||
const skipped = out.omitted?.length ?? 0;
|
||||
setNote(skipped ? t("已备份。{n} 个文件过大或无法读取,未包含在内。", { n: skipped }) : t("已备份。"));
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(reason(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const drop = async (id: string) => {
|
||||
if (dropping !== id) {
|
||||
setDropping(id);
|
||||
return;
|
||||
}
|
||||
setDropping("");
|
||||
try {
|
||||
await port.deleteBackup(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(reason(e));
|
||||
}
|
||||
};
|
||||
|
||||
if (!catalog) {
|
||||
return error ? <p className="acct-note" data-err="">{error}</p> : <p className="acct-note">{t("正在读取备份…")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bk">
|
||||
<p className="acct-note">
|
||||
{t("备份在本机用你设置的口令加密后才上传,服务端看不到其中内容。忘记口令就无法恢复。")}
|
||||
</p>
|
||||
|
||||
<div className="bk-cats" role="group" aria-label={t("备份内容")}>
|
||||
{catalog.categories.map((c) => (
|
||||
<label key={c.id} className="bk-cat" data-warn={c.id === "secrets" && chosen.has(c.id) ? "" : undefined}>
|
||||
<input type="checkbox" checked={chosen.has(c.id)} onChange={() => toggle(c.id)} data-action="backup.category" data-target={c.id} />
|
||||
<span className="nm">{t(CATEGORY_LABEL[c.id])}</span>
|
||||
<span className="why">{t(CATEGORY_NOTE[c.id])}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bk-fields">
|
||||
<label className="grow full">
|
||||
<span>{t("备注(可选)")}</span>
|
||||
<input data-action="backup.label" value={label} maxLength={80} placeholder={t("例如:公司笔记本")} onChange={(e) => setLabel(e.target.value)} />
|
||||
</label>
|
||||
<label className="grow">
|
||||
<span>{t("加密口令(至少 {n} 个字符)", { n: min })}</span>
|
||||
<input data-action="backup.passphrase" data-target="new" type="password" autoComplete="new-password" value={pass} onChange={(e) => setPass(e.target.value)} />
|
||||
</label>
|
||||
<label className="grow">
|
||||
<span>{t("再输一次")}</span>
|
||||
<input data-action="backup.passphrase" data-target="again" type="password" autoComplete="new-password" value={again} onChange={(e) => setAgain(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
{mismatch && <p className="acct-note" data-err="">{t("两次输入的口令不一致")}</p>}
|
||||
|
||||
<div className="bk-acts">
|
||||
<button className="act" data-primary data-action="backup.create" disabled={!ready} onClick={() => void create()}>
|
||||
{t(busy ? "正在加密上传…" : "备份到账号")}
|
||||
</button>
|
||||
<span className="acct-note">
|
||||
{t("已用 {n}/{max} 份", { n: catalog.backups.length, max: catalog.limits.maxCount })}
|
||||
</span>
|
||||
</div>
|
||||
{note && <p className="acct-note">{note}</p>}
|
||||
{error && <p className="acct-note" data-err="">{error}</p>}
|
||||
|
||||
{catalog.backups.length === 0 ? (
|
||||
<p className="acct-note">{t("账号里还没有备份。")}</p>
|
||||
) : (
|
||||
<ul className="bk-list">
|
||||
{catalog.backups.map((b) => (
|
||||
<li key={b.id} className="bk-row">
|
||||
<div className="who">
|
||||
<span className="nm">{b.label || t("未命名备份")}</span>
|
||||
<span className="meta">
|
||||
{new Date(b.createdAt).toLocaleString()} · {b.platform} · {b.appVersion || "—"} · {Math.max(1, Math.round(b.ciphertextBytes / 1024))} KB
|
||||
</span>
|
||||
<span className="meta">{b.categories.map((c) => t(CATEGORY_LABEL[c] ?? c)).join("、")}</span>
|
||||
</div>
|
||||
<button className="btn sm" data-action="backup.restore" data-target={b.id} onClick={() => setRestoring(b)}>
|
||||
{t("恢复…")}
|
||||
</button>
|
||||
<button className="btn sm" data-action="backup.delete" data-target={b.id} onClick={() => void drop(b.id)} onMouseLeave={() => setDropping("")}>
|
||||
{t(dropping === b.id ? "确认删除" : "删除")}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{restoring && <BackupRestore port={port} backup={restoring} onClose={() => setRestoring(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import type { AgentPort, BackupApplyResult, BackupCategory, BackupEntry, BackupPlan, BackupPlanItem, BackupPluginRef } from "../port/port";
|
||||
import { AddPlugin } from "./AddPlugin";
|
||||
|
||||
export const CATEGORY_LABEL: Record<BackupCategory, string> = {
|
||||
settings: "模型与界面设置",
|
||||
extensions: "技能、插件与 MCP",
|
||||
memory: "长期记忆",
|
||||
automation: "Hooks 与状态栏命令",
|
||||
secrets: "API 密钥",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<BackupPlanItem["status"], string> = {
|
||||
new: "本机没有",
|
||||
changed: "与本机不同",
|
||||
same: "与本机相同",
|
||||
install: "需安装",
|
||||
};
|
||||
|
||||
const CONSENT_LABEL: Record<string, string> = {
|
||||
executes: "我已看过这条命令,允许它在本机运行",
|
||||
endpoint: "我确认允许把对话和本机保存的密钥发往这个地址",
|
||||
replaces_secret: "我确认用备份里的密钥替换本机已保存的密钥",
|
||||
imports: "我已读过这份指令文件,允许它引用其中列出的文件",
|
||||
};
|
||||
|
||||
// The preview is the kernel's: which rows need consent, and why, is decided
|
||||
// there and rechecked on apply. This panel only collects two separate answers
|
||||
// per row — take it, and (where asked) allow what it does.
|
||||
export function BackupRestore({ port, backup, onClose }: { port: AgentPort; backup: BackupEntry; onClose: () => void }) {
|
||||
const [pass, setPass] = useState("");
|
||||
const [plan, setPlan] = useState<BackupPlan | null>(null);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const [allowed, setAllowed] = useState<Set<string>>(new Set());
|
||||
const [result, setResult] = useState<BackupApplyResult | null>(null);
|
||||
const [installing, setInstalling] = useState<BackupPluginRef | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const open = async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const p = await port.previewBackup(backup.id, pass);
|
||||
setPlan(p);
|
||||
setPicked(new Set(p.items.filter((i) => i.recommended).map((i) => i.id)));
|
||||
setAllowed(new Set());
|
||||
setPass("");
|
||||
} catch (e) {
|
||||
setError(reason(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const flip = (set: Set<string>, id: string) => {
|
||||
const next = new Set(set);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
};
|
||||
|
||||
const blocked = plan?.items.filter((i) => picked.has(i.id) && i.consent && !allowed.has(i.id)) ?? [];
|
||||
|
||||
const apply = async () => {
|
||||
if (!plan) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const ids = plan.items.filter((i) => picked.has(i.id)).map((i) => i.id);
|
||||
const consented = ids.filter((id) => allowed.has(id));
|
||||
setResult(await port.applyBackup(plan.planId, ids, consented));
|
||||
} catch (e) {
|
||||
setError(reason(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (result) {
|
||||
return (
|
||||
<div className="bk-restore" data-action="backup.result">
|
||||
<p className="acct-note">{t("已恢复 {n} 项。", { n: result.applied.length })}</p>
|
||||
{result.failed?.map((f) => (
|
||||
<p key={f.id} className="acct-note" data-err="">
|
||||
{f.id}:{f.error}
|
||||
</p>
|
||||
))}
|
||||
{result.reloadError && <p className="acct-note">{t("配置已写入,但当前会话未能重新加载:{e}", { e: result.reloadError })}</p>}
|
||||
{!!result.plugins?.length && (
|
||||
<>
|
||||
<p className="acct-note">{t("以下插件需要走正常的安装确认:")}</p>
|
||||
<ul className="bk-list">
|
||||
{result.plugins.map((p) => (
|
||||
<li key={p.name} className="bk-row">
|
||||
<div className="who">
|
||||
<span className="nm">{p.name}</span>
|
||||
<code className="meta">{p.source}{p.version ? " · " + p.version : ""}</code>
|
||||
</div>
|
||||
<button className="btn sm" data-action="backup.install-plugin" data-target={p.name} onClick={() => setInstalling(p)}>
|
||||
{t("安装…")}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
<div className="bk-acts">
|
||||
<button className="act" data-action="backup.done" onClick={onClose}>{t("完成")}</button>
|
||||
</div>
|
||||
{installing && (
|
||||
<AddPlugin port={port} source={installing.source} onClose={() => setInstalling(null)} onInstalled={() => setInstalling(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!plan) {
|
||||
return (
|
||||
<div className="bk-restore">
|
||||
<p className="acct-note">{t("输入备份「{name}」的加密口令,先预览它和本机的差异,不会立即写入。", { name: backup.label || t("未命名备份") })}</p>
|
||||
<div className="bk-fields">
|
||||
<label className="grow">
|
||||
<span>{t("加密口令")}</span>
|
||||
<input data-action="backup.passphrase" data-target="open" type="password" autoComplete="current-password" value={pass} onChange={(e) => setPass(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="bk-acts">
|
||||
<button className="act" data-primary data-action="backup.preview" disabled={!pass || busy} onClick={() => void open()}>
|
||||
{t(busy ? "正在解密…" : "预览差异")}
|
||||
</button>
|
||||
<button className="act" data-action="backup.cancel" onClick={onClose}>{t("取消")}</button>
|
||||
</div>
|
||||
{error && <p className="acct-note" data-err="">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bk-restore">
|
||||
{!plan.samePlatform && (
|
||||
<div className="find" data-lvl="warn" role="note">
|
||||
<span className="t">{t("这份备份来自 {p}", { p: plan.platform })}</span>
|
||||
<span className="why">{t("命令和路径在另一种系统上可能无法运行,这类项目默认不勾选。")}</span>
|
||||
</div>
|
||||
)}
|
||||
<ul className="bk-plan">
|
||||
{plan.items.map((it) => (
|
||||
<li key={it.id} className="bk-item" data-status={it.status} data-consent={it.consent ? "" : undefined}>
|
||||
<label className="take">
|
||||
<input type="checkbox" checked={picked.has(it.id)} onChange={() => setPicked((s) => flip(s, it.id))} data-action="backup.pick" data-target={it.id} />
|
||||
<span className="cat">{t(CATEGORY_LABEL[it.category])}</span>
|
||||
<span className="nm">{it.name}</span>
|
||||
<span className="st">{t(STATUS_LABEL[it.status])}</span>
|
||||
</label>
|
||||
{it.summary && <code className="sum">{it.summary}</code>}
|
||||
{it.details?.map((d) => (
|
||||
<code key={d} className="sum">{d}</code>
|
||||
))}
|
||||
{it.content !== undefined && it.content !== "" && <pre className="body">{it.content}</pre>}
|
||||
{it.previous && <span className="prev">{t("本机现为:")}<code>{it.previous}</code></span>}
|
||||
{!!it.files && <span className="prev">{t("{n} 个文件", { n: it.files })}</span>}
|
||||
{it.paths?.filter((p) => !p.exists).map((p) => (
|
||||
<span key={p.path} className="warn">{t("本机不存在:{p}", { p: p.path })}</span>
|
||||
))}
|
||||
{it.crossPlatform && <span className="warn">{t("来自另一种系统,可能无法运行")}</span>}
|
||||
{it.consent && picked.has(it.id) && (
|
||||
<label className="allow">
|
||||
<input type="checkbox" checked={allowed.has(it.id)} onChange={() => setAllowed((s) => flip(s, it.id))} data-action="backup.allow" data-target={it.id} />
|
||||
<span>{t(CONSENT_LABEL[it.consent])}</span>
|
||||
</label>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!!plan.omitted?.length && <p className="acct-note">{t("备份时有 {n} 个文件过大或无法读取,未包含在内。", { n: plan.omitted.length })}</p>}
|
||||
<div className="bk-acts">
|
||||
<button className="act" data-primary data-action="backup.apply" disabled={busy || picked.size === 0 || blocked.length > 0} onClick={() => void apply()}>
|
||||
{t(busy ? "正在恢复…" : "恢复所选 {n} 项", { n: picked.size })}
|
||||
</button>
|
||||
<button className="act" data-action="backup.cancel" onClick={onClose}>{t("取消")}</button>
|
||||
{blocked.length > 0 && <span className="acct-note">{t("还有 {n} 项需要逐项确认", { n: blocked.length })}</span>}
|
||||
</div>
|
||||
{error && <p className="acct-note" data-err="">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { Compaction } from "./Compaction";
|
||||
import { Sandbox } from "./Sandbox";
|
||||
import { BrowserTools } from "./BrowserTools";
|
||||
import { Account } from "./Account";
|
||||
import { Backup } from "./Backup";
|
||||
import { Providers } from "./Providers";
|
||||
import { activeKind, groupVendors } from "./Models";
|
||||
import { ModelUsage } from "./ModelUsage";
|
||||
@@ -727,6 +728,11 @@ export function Settings({ hub, onError, port, networkPort, networkHost, status,
|
||||
<Account port={port} state={acct} unread={accountUnread} reload={reloadAccount} />
|
||||
</Group>
|
||||
)}
|
||||
{at === "account" && acct?.signedIn && acct.user && (
|
||||
<Group id="backup" title={t("云备份")} hint={t("把本机的模型、扩展、记忆等配置加密保存到账号,换机器或重装后按项恢复。")}>
|
||||
<Backup port={port} />
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{at === "versions" && (
|
||||
<Group id="versions"
|
||||
|
||||
@@ -235,6 +235,8 @@ export const SETTINGS: SettingEntry[] = [
|
||||
// the window either.
|
||||
{ section: "remote", anchor: "phone", title: "手机访问", scope: "machine", apply: "immediate", keywords: ["手机", "扫码", "二维码", "局域网", "配对", "phone", "mobile", "qr"] },
|
||||
{ section: "account", anchor: "account", title: "账号", scope: "account", apply: "immediate", keywords: ["登录", "社区"] },
|
||||
// Shown only while signed in; restoring writes through the ordinary config paths and reloads the runtime.
|
||||
{ section: "account", anchor: "backup", title: "云备份", scope: "account", apply: "immediate", keywords: ["备份", "恢复", "换机", "同步", "导出"] },
|
||||
{ section: "versions", anchor: "versions", title: "版本", scope: "machine", apply: "immediate", keywords: ["更新", "升级"] },
|
||||
{ section: "memory", anchor: "memory", title: "记忆", scope: "chosen", apply: "immediate", keywords: ["记住", "忘记", "事实"] },
|
||||
{ section: "usage", anchor: "usage", title: "用量与成本", scope: "machine", apply: "none", keywords: ["token", "花费", "缓存命中"] },
|
||||
|
||||
@@ -18,7 +18,7 @@ const ROUTES = [
|
||||
"/plan", "/plan-decision", "/goal", "/resume", "/models", "/tool-approval-mode", "/preset",
|
||||
"/model", "/effort", "/new", "/sessions", "/delete-session", "/provider-setup",
|
||||
"/inbox", "/trajectory", "/mcp", "/skills", "/complete", "/prompt", "/workspace", "/capability-scope",
|
||||
"/providers", "/decision-models", "/roles", "/account", "/hooks", "/memory", "/network", "/shell", "/todos",
|
||||
"/providers", "/decision-models", "/roles", "/account", "/backups", "/hooks", "/memory", "/network", "/shell", "/todos",
|
||||
"/changes", "/attachments", "/drop", "/checkpoints", "/branches", "/compact", "/compaction", "/rewind",
|
||||
"/extensions", "/themes", "/plugins", "/surfaces",
|
||||
"/fork", "/summarize", "/forget", "/bypass", "/auto-approve-tools",
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package configbackup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/base/fileutil"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/ext/hook"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrConsentRequired = errors.New("configbackup: some items need consent before they are restored")
|
||||
ErrUnknownItem = errors.New("configbackup: the backup has no such item")
|
||||
)
|
||||
|
||||
// ConsentError names the selected items that were not consented to.
|
||||
type ConsentError struct{ IDs []string }
|
||||
|
||||
func (e *ConsentError) Error() string {
|
||||
return fmt.Sprintf("%v: %s", ErrConsentRequired, strings.Join(e.IDs, ", "))
|
||||
}
|
||||
|
||||
func (e *ConsentError) Is(target error) bool { return target == ErrConsentRequired }
|
||||
|
||||
// ApplyRequest is the person's decision on one preview.
|
||||
type ApplyRequest struct {
|
||||
PlanID string `json:"planId"`
|
||||
Items []string `json:"items"`
|
||||
Consented []string `json:"consented"`
|
||||
}
|
||||
|
||||
// PluginRef is a plugin to bring back through the plugin install flow.
|
||||
type PluginRef struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
}
|
||||
|
||||
// ItemFailure is one selected item that could not be written.
|
||||
type ItemFailure struct {
|
||||
ID string `json:"id"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// ApplyResult reports what landed.
|
||||
type ApplyResult struct {
|
||||
Applied []string `json:"applied"`
|
||||
Plugins []PluginRef `json:"plugins,omitempty"`
|
||||
Failed []ItemFailure `json:"failed,omitempty"`
|
||||
}
|
||||
|
||||
// Apply writes the selected items of a held preview. It refuses the whole
|
||||
// request, writing nothing and keeping the preview, when a selected item needs
|
||||
// consent it was not given.
|
||||
func (p *Planner) Apply(req ApplyRequest) (*ApplyResult, error) {
|
||||
s, err := p.peek(req.PlanID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID := make(map[string]Item, len(s.Items))
|
||||
for _, it := range s.Items {
|
||||
byID[it.ID] = it
|
||||
}
|
||||
var chosen []Item
|
||||
for _, id := range req.Items {
|
||||
it, ok := byID[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %q", ErrUnknownItem, id)
|
||||
}
|
||||
chosen = append(chosen, it)
|
||||
}
|
||||
local, err := collectLocal(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var unconsented []string
|
||||
for _, it := range chosen {
|
||||
if consentFor(it, s, local) != "" && !slices.Contains(req.Consented, it.ID) {
|
||||
unconsented = append(unconsented, it.ID)
|
||||
}
|
||||
}
|
||||
if len(unconsented) > 0 {
|
||||
return nil, &ConsentError{IDs: unconsented}
|
||||
}
|
||||
if _, err := p.take(req.PlanID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return write(chosen), nil
|
||||
}
|
||||
|
||||
func (p *Planner) peek(id string) (*Snapshot, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
h, ok := p.plans[id]
|
||||
if !ok || p.now().After(h.expires) {
|
||||
return nil, ErrPlanExpired
|
||||
}
|
||||
return h.snapshot, nil
|
||||
}
|
||||
|
||||
func write(items []Item) *ApplyResult {
|
||||
res := &ApplyResult{Applied: []string{}}
|
||||
var cfgItems, hooks []Item
|
||||
for _, it := range items {
|
||||
switch it.Kind {
|
||||
case KindGeneral, KindProvider, KindInterface, KindMCP, KindStatusline:
|
||||
cfgItems = append(cfgItems, it)
|
||||
case KindHook:
|
||||
hooks = append(hooks, it)
|
||||
case KindPlugin:
|
||||
var d pluginData
|
||||
if err := json.Unmarshal(it.Data, &d); err != nil {
|
||||
res.fail(it.ID, err)
|
||||
continue
|
||||
}
|
||||
res.Plugins = append(res.Plugins, PluginRef{Name: it.Name, Source: d.Source, Version: d.Version, Commit: d.Commit})
|
||||
default:
|
||||
res.record(it.ID, writeOne(it))
|
||||
}
|
||||
}
|
||||
if len(cfgItems) > 0 {
|
||||
err := config.EditConfigFile(config.UserConfigPath(), func(cfg *config.Config) error {
|
||||
for _, it := range cfgItems {
|
||||
if err := applyConfigItem(cfg, it); err != nil {
|
||||
return fmt.Errorf("%s: %w", it.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
for _, it := range cfgItems {
|
||||
res.record(it.ID, err)
|
||||
}
|
||||
}
|
||||
if len(hooks) > 0 {
|
||||
err := applyHooks(hooks)
|
||||
for _, it := range hooks {
|
||||
res.record(it.ID, err)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (r *ApplyResult) record(id string, err error) {
|
||||
if err != nil {
|
||||
r.fail(id, err)
|
||||
return
|
||||
}
|
||||
r.Applied = append(r.Applied, id)
|
||||
}
|
||||
|
||||
func (r *ApplyResult) fail(id string, err error) {
|
||||
r.Failed = append(r.Failed, ItemFailure{ID: id, Error: err.Error()})
|
||||
}
|
||||
|
||||
func applyConfigItem(cfg *config.Config, it Item) error {
|
||||
switch it.Kind {
|
||||
case KindGeneral:
|
||||
var g generalData
|
||||
if err := json.Unmarshal(it.Data, &g); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.DefaultModel, cfg.Language = g.DefaultModel, g.Language
|
||||
return nil
|
||||
case KindInterface:
|
||||
var look interfaceSettings
|
||||
if err := decodeTOML(it.Data, &look); err != nil {
|
||||
return err
|
||||
}
|
||||
look.applyTo(cfg)
|
||||
return nil
|
||||
case KindProvider:
|
||||
var entry config.ProviderEntry
|
||||
if err := decodeTOML(it.Data, &entry); err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.Name != it.Name {
|
||||
return fmt.Errorf("%w: provider %q", ErrMalformed, it.Name)
|
||||
}
|
||||
for _, have := range cfg.Providers {
|
||||
if have.Name == entry.Name && endpoints(have) == endpoints(entry) {
|
||||
entry.Headers = keepLocalSecrets(entry.Headers, have.Headers)
|
||||
}
|
||||
}
|
||||
return cfg.UpsertProvider(entry)
|
||||
case KindMCP:
|
||||
var entry config.PluginEntry
|
||||
if err := decodeTOML(it.Data, &entry); err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.Name != it.Name {
|
||||
return fmt.Errorf("%w: MCP server %q", ErrMalformed, it.Name)
|
||||
}
|
||||
for _, have := range cfg.Plugins {
|
||||
if have.Name == entry.Name && have.URL == entry.URL && have.Command == entry.Command {
|
||||
entry.Env = keepLocalSecrets(entry.Env, have.Env)
|
||||
entry.Headers = keepLocalSecrets(entry.Headers, have.Headers)
|
||||
}
|
||||
}
|
||||
return cfg.UpsertPlugin(entry)
|
||||
case KindStatusline:
|
||||
var s statuslineData
|
||||
if err := json.Unmarshal(it.Data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Statusline.Command = s.Command
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: kind %q", ErrUnknownItem, it.Kind)
|
||||
}
|
||||
|
||||
// applyHooks adds each restored hook that is not already configured. A hook
|
||||
// is never replaced: the name is derived from what it runs, so "the same
|
||||
// hook" with a different command is a different hook.
|
||||
func applyHooks(items []Item) error {
|
||||
settings, err := globalHooks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if settings.Hooks == nil {
|
||||
settings.Hooks = map[hook.Event][]hook.HookConfig{}
|
||||
}
|
||||
for _, it := range items {
|
||||
var d hookData
|
||||
if err := json.Unmarshal(it.Data, &d); err != nil {
|
||||
return err
|
||||
}
|
||||
event := hook.Event(d.Event)
|
||||
if !hook.IsKnownEvent(d.Event) {
|
||||
return fmt.Errorf("%s: unknown hook event %q", it.ID, d.Event)
|
||||
}
|
||||
if slices.ContainsFunc(settings.Hooks[event], func(h hook.HookConfig) bool {
|
||||
return hookName(d.Event, h) == hookName(d.Event, d.Hook)
|
||||
}) {
|
||||
continue
|
||||
}
|
||||
settings.Hooks[event] = append(settings.Hooks[event], d.Hook)
|
||||
}
|
||||
return hook.Save(hook.ScopeGlobal, "", settings)
|
||||
}
|
||||
|
||||
func writeOne(it Item) error {
|
||||
switch it.Kind {
|
||||
case KindSkill:
|
||||
var d skillData
|
||||
if err := json.Unmarshal(it.Data, &d); err != nil {
|
||||
return err
|
||||
}
|
||||
if !validSegment(it.Name) {
|
||||
return fmt.Errorf("%w: %q", ErrUnsafePath, it.Name)
|
||||
}
|
||||
for _, f := range d.Files {
|
||||
if first, _, _ := strings.Cut(f.Path, "/"); first != it.Name {
|
||||
return fmt.Errorf("%w: %q is outside skill %q", ErrUnsafePath, f.Path, it.Name)
|
||||
}
|
||||
}
|
||||
for _, f := range d.Files {
|
||||
if err := writeFile(skillsRoot(), f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case KindMemory:
|
||||
var d memoryData
|
||||
if err := json.Unmarshal(it.Data, &d); err != nil {
|
||||
return err
|
||||
}
|
||||
if d.Root == memoryRootDocs && !slices.Contains(memoryDocNames(), d.Path) {
|
||||
return fmt.Errorf("%w: %q is not a standing-instruction file", ErrUnsafePath, d.Path)
|
||||
}
|
||||
return writeFile(memoryRoot(d.Root), d.fileData)
|
||||
case KindSecret:
|
||||
var d secretData
|
||||
if err := json.Unmarshal(it.Data, &d); err != nil {
|
||||
return err
|
||||
}
|
||||
if d.Key != it.Name || deniedKey(d.Key) || !config.IsValidCredentialKey(d.Key) {
|
||||
return fmt.Errorf("%w: credential %q", ErrUnknownItem, d.Key)
|
||||
}
|
||||
_, err := config.SetCredential(d.Key, d.Value)
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w: kind %q", ErrUnknownItem, it.Kind)
|
||||
}
|
||||
|
||||
func writeFile(root string, f fileData) error {
|
||||
if root == "" {
|
||||
return fmt.Errorf("%w: no destination root", ErrUnsafePath)
|
||||
}
|
||||
dest, err := safeJoin(root, f.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(f.Data) > maxFileBytes {
|
||||
return ErrTooLarge
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := safeJoin(root, f.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.AtomicWriteFile(dest, f.Data, 0o644)
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package configbackup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
fileencoding "reasonix/internal/base/fileutil/encoding"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/ext/hook"
|
||||
"reasonix/internal/ext/pluginpkg"
|
||||
"reasonix/internal/state/instruction"
|
||||
)
|
||||
|
||||
// maxFileBytes bounds one skill or memory file. A backup is configuration,
|
||||
// and a checked-in model or dataset inside a skill folder is not.
|
||||
const maxFileBytes = 512 << 10
|
||||
|
||||
// accountTokenKey never travels: it is this machine's session, not a secret the
|
||||
// user configured, and restoring it elsewhere would clone a login.
|
||||
const accountTokenKey = "REASONIX_ACCOUNT_TOKEN"
|
||||
|
||||
// CollectOptions says what to put in a snapshot.
|
||||
type CollectOptions struct {
|
||||
Categories []Category
|
||||
AppVersion string
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
// Collect reads this machine's user-level setup into a snapshot.
|
||||
func Collect(opts CollectOptions) (*Snapshot, error) {
|
||||
if len(opts.Categories) == 0 {
|
||||
return nil, ErrNoCategories
|
||||
}
|
||||
now := opts.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
s := &Snapshot{
|
||||
Format: FormatVersion, CreatedAt: now.UTC(), AppVersion: opts.AppVersion,
|
||||
Platform: runtime.GOOS + "/" + runtime.GOARCH, Categories: opts.Categories,
|
||||
}
|
||||
c := newCollector(slices.Contains(opts.Categories, CategorySecrets))
|
||||
if err := c.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, cat := range opts.Categories {
|
||||
if err := c.collect(cat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
s.Items, s.Omitted = c.items, c.omitted
|
||||
return s, nil
|
||||
}
|
||||
|
||||
type collector struct {
|
||||
withSecrets bool
|
||||
cfg *config.Config
|
||||
items []Item
|
||||
omitted []Omission
|
||||
}
|
||||
|
||||
func newCollector(withSecrets bool) *collector { return &collector{withSecrets: withSecrets} }
|
||||
|
||||
func (c *collector) load() error {
|
||||
cfg, err := config.LoadForEditReadOnlyStrict(config.UserConfigPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.cfg = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *collector) add(kind, name string, data any) error {
|
||||
raw, ok := data.(json.RawMessage)
|
||||
if !ok {
|
||||
var err error
|
||||
if raw, err = json.Marshal(data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c.items = append(c.items, Item{ID: itemID(kind, name), Category: categoryOfKind(kind), Kind: kind, Name: name, Data: raw})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *collector) collect(cat Category) error {
|
||||
switch cat {
|
||||
case CategorySettings:
|
||||
return c.settings()
|
||||
case CategoryExtensions:
|
||||
return c.extensions()
|
||||
case CategoryMemory:
|
||||
return c.memory()
|
||||
case CategoryAutomation:
|
||||
return c.automation()
|
||||
case CategorySecrets:
|
||||
return c.secrets()
|
||||
}
|
||||
return ErrUnknownCategory
|
||||
}
|
||||
|
||||
func (c *collector) settings() error {
|
||||
if err := c.add(KindGeneral, "general", generalData{DefaultModel: c.cfg.DefaultModel, Language: c.cfg.Language}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range c.cfg.Providers {
|
||||
entry := config.ProviderEntryConfigSnapshot(p)
|
||||
if !c.withSecrets {
|
||||
_, entry.Headers = stripSecrets(entry.Name, nil, entry.Headers)
|
||||
for _, u := range []*string{&entry.BaseURL, &entry.ChatURL, &entry.RequestURL, &entry.ModelsURL, &entry.BalanceURL} {
|
||||
*u = stripURLSecrets(*u)
|
||||
}
|
||||
}
|
||||
raw, err := encodeTOML(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.add(KindProvider, entry.Name, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
raw, err := encodeTOML(lookOf(c.cfg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.add(KindInterface, "interface", raw)
|
||||
}
|
||||
|
||||
func (c *collector) extensions() error {
|
||||
if err := c.skills(); err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := pluginpkg.LoadState(config.ReasonixHomeDir())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range st.Plugins {
|
||||
if err := c.add(KindPlugin, p.Name, pluginData{Source: stripURLSecrets(p.Source), Version: p.Version, Commit: p.Commit}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, p := range c.cfg.Plugins {
|
||||
entry := p
|
||||
if !c.withSecrets {
|
||||
entry.Env, entry.Headers = stripSecrets(entry.Name, entry.Env, entry.Headers)
|
||||
entry.URL = stripURLSecrets(entry.URL)
|
||||
}
|
||||
raw, err := encodeTOML(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.add(KindMCP, entry.Name, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *collector) skills() error {
|
||||
root := skillsRoot()
|
||||
entries, err := os.ReadDir(root)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, ".") || !validSegment(name) {
|
||||
continue
|
||||
}
|
||||
files := c.readTree(KindSkill, root, name)
|
||||
if len(files) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := c.add(KindSkill, name, skillData{Files: files}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readTree reads the regular files at or under root/rel. Symlinks are left out:
|
||||
// on the other machine one would dangle or point somewhere it should not.
|
||||
func (c *collector) readTree(kind, root, rel string) []fileData {
|
||||
var out []fileData
|
||||
_ = filepath.WalkDir(filepath.Join(root, rel), func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
r, _ := filepath.Rel(root, p)
|
||||
r = filepath.ToSlash(r)
|
||||
if d.IsDir() {
|
||||
if skipDirs[d.Name()] || (r != "." && strings.HasPrefix(d.Name(), ".")) {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(d.Name(), ".") {
|
||||
c.omitted = append(c.omitted, Omission{Kind: kind, Name: r, Reason: OmitHidden})
|
||||
return nil
|
||||
}
|
||||
if !d.Type().IsRegular() {
|
||||
c.omitted = append(c.omitted, Omission{Kind: kind, Name: r, Reason: OmitNotFile})
|
||||
return nil
|
||||
}
|
||||
if f, ok := c.readFile(kind, p, r); ok {
|
||||
out = append(out, f)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
var skipDirs = map[string]bool{".git": true, ".hg": true, ".svn": true, "node_modules": true}
|
||||
|
||||
func (c *collector) readFile(kind, abs, rel string) (fileData, bool) {
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
c.omitted = append(c.omitted, Omission{Kind: kind, Name: rel, Reason: OmitUnreadable})
|
||||
return fileData{}, false
|
||||
}
|
||||
if info.Size() > maxFileBytes {
|
||||
c.omitted = append(c.omitted, Omission{Kind: kind, Name: rel, Reason: OmitTooLarge})
|
||||
return fileData{}, false
|
||||
}
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
c.omitted = append(c.omitted, Omission{Kind: kind, Name: rel, Reason: OmitUnreadable})
|
||||
return fileData{}, false
|
||||
}
|
||||
return fileData{Path: rel, Data: data}, true
|
||||
}
|
||||
|
||||
func (c *collector) memory() error {
|
||||
docs := memoryDocsRoot()
|
||||
if docs == "" {
|
||||
return nil
|
||||
}
|
||||
for _, name := range memoryDocNames() {
|
||||
abs := filepath.Join(docs, name)
|
||||
if info, err := os.Lstat(abs); err != nil || !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
if f, ok := c.readFile(KindMemory, abs, name); ok {
|
||||
if err := c.add(KindMemory, memoryRootDocs+"/"+name, memoryData{Root: memoryRootDocs, fileData: f}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, f := range c.readTree(KindMemory, memoryFactsRoot(), ".") {
|
||||
f.Path = strings.TrimPrefix(f.Path, "./")
|
||||
if err := c.add(KindMemory, memoryRootFacts+"/"+f.Path, memoryData{Root: memoryRootFacts, fileData: f}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *collector) automation() error {
|
||||
settings, err := globalHooks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, event := range sortedEvents(settings) {
|
||||
for _, h := range settings.Hooks[event] {
|
||||
if strings.TrimSpace(h.Command) == "" {
|
||||
continue
|
||||
}
|
||||
if !c.withSecrets {
|
||||
h.Env, _ = stripSecrets(hookName(string(event), h), h.Env, nil)
|
||||
}
|
||||
if err := c.add(KindHook, hookName(string(event), h), hookData{Event: string(event), Hook: h}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if cmd := strings.TrimSpace(c.cfg.Statusline.Command); cmd != "" {
|
||||
return c.add(KindStatusline, "statusline", statuslineData{Command: cmd})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// secrets carries the stored keys the saved providers point at. Only keys in
|
||||
// Reasonix's own credential store travel; a key set in the shell environment
|
||||
// is that environment's, not this setup's.
|
||||
func (c *collector) secrets() error {
|
||||
seen := map[string]bool{}
|
||||
for _, p := range c.cfg.Providers {
|
||||
key := strings.TrimSpace(p.APIKeyEnv)
|
||||
if key == "" || key == accountTokenKey || seen[key] || !config.CredentialStored(key) {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
value := config.ResolveCredential(key).Value
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if err := c.add(KindSecret, key, secretData{Key: key, Value: value}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func globalHooks() (hook.Settings, error) {
|
||||
var s hook.Settings
|
||||
body, err := fileencoding.ReadFileUTF8(hook.GlobalSettingsPath(""))
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return s, nil
|
||||
}
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &s); err != nil {
|
||||
return s, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func sortedEvents(s hook.Settings) []hook.Event {
|
||||
events := make([]hook.Event, 0, len(s.Hooks))
|
||||
for e := range s.Hooks {
|
||||
events = append(events, e)
|
||||
}
|
||||
slices.Sort(events)
|
||||
return events
|
||||
}
|
||||
|
||||
func memoryDocNames() []string {
|
||||
return append(slices.Clone(instruction.DocumentNames), instruction.LocalDocumentNames...)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package configbackup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/ext/hook"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) { testenv.RunWithIsolatedUserState(m) }
|
||||
|
||||
const passphrase = "correct horse battery"
|
||||
|
||||
const userConfig = `
|
||||
default_model = "work/model-a"
|
||||
|
||||
[statusline]
|
||||
command = "/usr/local/bin/status --fast"
|
||||
|
||||
[[providers]]
|
||||
name = "work"
|
||||
kind = "openai"
|
||||
base_url = "https://api.example.com/v1"
|
||||
api_key_env = "WORK_API_KEY"
|
||||
models = ["model-a"]
|
||||
headers = { X-Org-Token = "literal-header-secret" }
|
||||
|
||||
[[plugins]]
|
||||
name = "files"
|
||||
command = "npx"
|
||||
args = ["-y", "files-server"]
|
||||
env = { FILES_TOKEN = "literal-env-secret" }
|
||||
`
|
||||
|
||||
// machine points every user root at a fresh directory and writes a config.
|
||||
func machine(t *testing.T, cfg string) string {
|
||||
t.Helper()
|
||||
dir := testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", filepath.Join(dir, "home"))
|
||||
t.Setenv("REASONIX_STATE_HOME", filepath.Join(dir, "state"))
|
||||
t.Setenv("WORK_API_KEY", "")
|
||||
os.Unsetenv("WORK_API_KEY")
|
||||
must(t, os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755))
|
||||
must(t, os.WriteFile(config.UserConfigPath(), []byte(cfg), 0o600))
|
||||
return dir
|
||||
}
|
||||
|
||||
func must(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func collectAll(t *testing.T, cats ...Category) *Snapshot {
|
||||
t.Helper()
|
||||
s, err := Collect(CollectOptions{Categories: cats, AppVersion: "test"})
|
||||
must(t, err)
|
||||
return s
|
||||
}
|
||||
|
||||
func TestSealOpenRoundTripAndRejections(t *testing.T) {
|
||||
machine(t, userConfig)
|
||||
s := collectAll(t, CategorySettings)
|
||||
sealed, err := Seal(s, passphrase)
|
||||
must(t, err)
|
||||
if bytes.Contains(sealed, []byte("api.example.com")) {
|
||||
t.Fatal("sealed backup carries plaintext")
|
||||
}
|
||||
back, err := Open(sealed, passphrase)
|
||||
must(t, err)
|
||||
if len(back.Items) != len(s.Items) || back.Format != FormatVersion {
|
||||
t.Fatalf("round trip lost items: %d vs %d", len(back.Items), len(s.Items))
|
||||
}
|
||||
if _, err := Open(sealed, "wrong passphrase!"); !errors.Is(err, ErrCannotDecrypt) {
|
||||
t.Fatalf("wrong passphrase: %v", err)
|
||||
}
|
||||
tampered := slices.Clone(sealed)
|
||||
tampered[len(tampered)-1] ^= 1
|
||||
if _, err := Open(tampered, passphrase); !errors.Is(err, ErrCannotDecrypt) {
|
||||
t.Fatalf("tampered ciphertext: %v", err)
|
||||
}
|
||||
// The header is associated data: lowering the KDF cost must not decrypt.
|
||||
h, n, err := parseHeader(sealed)
|
||||
must(t, err)
|
||||
h.Time = 1
|
||||
prefix, err := envelopePrefix(h)
|
||||
must(t, err)
|
||||
if _, err := Open(append(prefix, sealed[n:]...), passphrase); !errors.Is(err, ErrCannotDecrypt) {
|
||||
t.Fatalf("rewritten header: %v", err)
|
||||
}
|
||||
h.Memory = 1 << 30
|
||||
prefix, _ = envelopePrefix(h)
|
||||
if _, err := Open(append(prefix, sealed[n:]...), passphrase); !errors.Is(err, ErrMalformed) {
|
||||
t.Fatalf("hostile KDF memory: %v", err)
|
||||
}
|
||||
if _, err := Seal(s, "short"); !errors.Is(err, ErrWeakPassphrase) {
|
||||
t.Fatalf("short passphrase: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectWithoutSecretsStripsLiteralCredentials(t *testing.T) {
|
||||
machine(t, userConfig)
|
||||
_, err := config.SetCredential("WORK_API_KEY", "sk-stored-key")
|
||||
must(t, err)
|
||||
_, err = config.SetCredential(accountTokenKey, "session-token")
|
||||
must(t, err)
|
||||
s := collectAll(t, CategorySettings, CategoryExtensions, CategoryAutomation)
|
||||
raw, _ := json.Marshal(s)
|
||||
for _, secret := range []string{"literal-header-secret", "literal-env-secret", "sk-stored-key", "session-token"} {
|
||||
if bytes.Contains(raw, []byte(secret)) {
|
||||
t.Fatalf("snapshot without secrets carries %q", secret)
|
||||
}
|
||||
}
|
||||
withSecrets := collectAll(t, CategorySettings, CategorySecrets)
|
||||
raw, _ = json.Marshal(withSecrets)
|
||||
if !bytes.Contains(raw, []byte("sk-stored-key")) || !bytes.Contains(raw, []byte("literal-header-secret")) {
|
||||
t.Fatal("secrets category left the stored key out")
|
||||
}
|
||||
if bytes.Contains(raw, []byte("session-token")) {
|
||||
t.Fatal("the account session token must never travel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreRefusesExecutingItemsWithoutConsent(t *testing.T) {
|
||||
machine(t, userConfig)
|
||||
must(t, hook.Save(hook.ScopeGlobal, "", hook.Settings{Hooks: map[hook.Event][]hook.HookConfig{
|
||||
"PreToolUse": {{Match: "bash", Command: "echo guarded"}},
|
||||
}}))
|
||||
s := collectAll(t, CategoryAutomation, CategoryExtensions)
|
||||
|
||||
machine(t, "")
|
||||
p := NewPlanner()
|
||||
plan, err := p.Preview(s)
|
||||
must(t, err)
|
||||
var hookID string
|
||||
for _, it := range plan.Items {
|
||||
if it.Kind == KindHook {
|
||||
hookID = it.ID
|
||||
if it.Consent != ConsentExecutes || it.Recommended {
|
||||
t.Fatalf("hook row: %+v", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err = p.Apply(ApplyRequest{PlanID: plan.ID, Items: []string{hookID, "statusline:statusline", "mcp:files"}})
|
||||
var ce *ConsentError
|
||||
if !errors.As(err, &ce) || !errors.Is(err, ErrConsentRequired) || len(ce.IDs) != 3 {
|
||||
t.Fatalf("apply without consent: %v", err)
|
||||
}
|
||||
if got, _ := globalHooks(); len(got.Hooks) != 0 {
|
||||
t.Fatal("a refused apply wrote hooks")
|
||||
}
|
||||
res, err := p.Apply(ApplyRequest{PlanID: plan.ID, Items: []string{hookID}, Consented: []string{hookID}})
|
||||
must(t, err)
|
||||
if len(res.Applied) != 1 || len(res.Failed) != 0 {
|
||||
t.Fatalf("consented apply: %+v", res)
|
||||
}
|
||||
got, _ := globalHooks()
|
||||
if len(got.Hooks["PreToolUse"]) != 1 || got.Hooks["PreToolUse"][0].Command != "echo guarded" {
|
||||
t.Fatalf("hook not restored: %+v", got)
|
||||
}
|
||||
if _, err := p.Apply(ApplyRequest{PlanID: plan.ID, Items: []string{hookID}, Consented: []string{hookID}}); !errors.Is(err, ErrPlanExpired) {
|
||||
t.Fatalf("a preview backs one apply: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderRedirectNeedsConsentAndLeavesLocalHeadersBehind(t *testing.T) {
|
||||
machine(t, userConfig)
|
||||
s := collectAll(t, CategorySettings)
|
||||
machine(t, strings.Replace(userConfig, "api.example.com", "elsewhere.example.net", 1))
|
||||
p := NewPlanner()
|
||||
plan, err := p.Preview(s)
|
||||
must(t, err)
|
||||
row := planRow(t, plan, "provider:work")
|
||||
if row.Status != StatusChanged || row.Consent != ConsentEndpoint {
|
||||
t.Fatalf("provider row: %+v", row)
|
||||
}
|
||||
if _, err := p.Apply(ApplyRequest{PlanID: plan.ID, Items: []string{row.ID}}); !errors.Is(err, ErrConsentRequired) {
|
||||
t.Fatalf("redirect without consent: %v", err)
|
||||
}
|
||||
_, err = p.Apply(ApplyRequest{PlanID: plan.ID, Items: []string{row.ID}, Consented: []string{row.ID}})
|
||||
must(t, err)
|
||||
cfg, err := config.LoadForEditReadOnlyStrict(config.UserConfigPath())
|
||||
must(t, err)
|
||||
// The local literal belonged to the old endpoint; carrying it to the new one
|
||||
// would be the redirect the consent was about, so the placeholder stays.
|
||||
if cfg.Providers[0].BaseURL != "https://api.example.com/v1" || cfg.Providers[0].Headers["X-Org-Token"] == "literal-header-secret" {
|
||||
t.Fatalf("restored provider: %+v", cfg.Providers[0])
|
||||
}
|
||||
}
|
||||
|
||||
func planRow(t *testing.T, plan *Plan, id string) PlanItem {
|
||||
t.Helper()
|
||||
for _, it := range plan.Items {
|
||||
if it.ID == id {
|
||||
return it
|
||||
}
|
||||
}
|
||||
t.Fatalf("no row %s", id)
|
||||
return PlanItem{}
|
||||
}
|
||||
|
||||
func TestFileItemsCannotLeaveTheirRoot(t *testing.T) {
|
||||
dir := machine(t, "")
|
||||
cases := []Item{
|
||||
fileItem(t, KindSkill, "evil", skillData{Files: []fileData{{Path: "evil/../../escape.md", Data: []byte("x")}}}),
|
||||
fileItem(t, KindSkill, "evil", skillData{Files: []fileData{{Path: "other/SKILL.md", Data: []byte("x")}}}),
|
||||
fileItem(t, KindSkill, "evil", skillData{Files: []fileData{{Path: `evil/..\..\escape.md`, Data: []byte("x")}}}),
|
||||
fileItem(t, KindMemory, "docs/settings.json", memoryData{Root: memoryRootDocs, fileData: fileData{Path: "settings.json", Data: []byte("x")}}),
|
||||
fileItem(t, KindMemory, "facts/C:/x.md", memoryData{Root: memoryRootFacts, fileData: fileData{Path: "C:/x.md", Data: []byte("x")}}),
|
||||
}
|
||||
for _, it := range cases {
|
||||
if err := writeOne(it); !errors.Is(err, ErrUnsafePath) {
|
||||
t.Fatalf("%s: %v", it.ID, err)
|
||||
}
|
||||
}
|
||||
outside := filepath.Join(dir, "outside")
|
||||
must(t, os.MkdirAll(outside, 0o755))
|
||||
must(t, os.MkdirAll(skillsRoot(), 0o755))
|
||||
if err := os.Symlink(outside, filepath.Join(skillsRoot(), "linked")); err != nil {
|
||||
t.Skip("symlinks unavailable:", err)
|
||||
}
|
||||
it := fileItem(t, KindSkill, "linked", skillData{Files: []fileData{{Path: "linked/SKILL.md", Data: []byte("x")}}})
|
||||
if err := writeOne(it); !errors.Is(err, ErrUnsafePath) {
|
||||
t.Fatalf("write through a symlink: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "SKILL.md")); err == nil {
|
||||
t.Fatal("file landed outside the skills root")
|
||||
}
|
||||
}
|
||||
|
||||
func fileItem(t *testing.T, kind, name string, data any) Item {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(data)
|
||||
must(t, err)
|
||||
return Item{ID: itemID(kind, name), Category: categoryOfKind(kind), Kind: kind, Name: name, Data: raw}
|
||||
}
|
||||
|
||||
func TestSkillsAndMemoryRoundTrip(t *testing.T) {
|
||||
machine(t, "")
|
||||
must(t, os.MkdirAll(filepath.Join(skillsRoot(), "review"), 0o755))
|
||||
must(t, os.WriteFile(filepath.Join(skillsRoot(), "review", "SKILL.md"), []byte("# review"), 0o644))
|
||||
must(t, os.MkdirAll(memoryFactsRoot(), 0o755))
|
||||
must(t, os.WriteFile(filepath.Join(memoryFactsRoot(), "tabs.md"), []byte("prefers tabs"), 0o644))
|
||||
must(t, os.WriteFile(filepath.Join(memoryDocsRoot(), "REASONIX.md"), []byte("be terse"), 0o644))
|
||||
s := collectAll(t, CategoryExtensions, CategoryMemory)
|
||||
|
||||
machine(t, "")
|
||||
p := NewPlanner()
|
||||
plan, err := p.Preview(s)
|
||||
must(t, err)
|
||||
var ids []string
|
||||
for _, it := range plan.Items {
|
||||
if it.Recommended {
|
||||
ids = append(ids, it.ID)
|
||||
}
|
||||
}
|
||||
res, err := p.Apply(ApplyRequest{PlanID: plan.ID, Items: ids})
|
||||
must(t, err)
|
||||
if len(res.Failed) != 0 || len(res.Applied) != 3 {
|
||||
t.Fatalf("apply: %+v (ids %v)", res, ids)
|
||||
}
|
||||
for path, want := range map[string]string{
|
||||
filepath.Join(skillsRoot(), "review", "SKILL.md"): "# review",
|
||||
filepath.Join(memoryFactsRoot(), "tabs.md"): "prefers tabs",
|
||||
filepath.Join(memoryDocsRoot(), "REASONIX.md"): "be terse",
|
||||
} {
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil || string(got) != want {
|
||||
t.Fatalf("%s = %q, %v", path, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotCannotRefileAKindUnderAnotherCategory(t *testing.T) {
|
||||
s := &Snapshot{Format: 1, Items: []Item{{ID: "hook:x", Category: CategorySettings, Kind: KindHook, Name: "x"}}}
|
||||
if err := s.validate(); !errors.Is(err, ErrMalformed) {
|
||||
t.Fatalf("refiled hook: %v", err)
|
||||
}
|
||||
s = &Snapshot{Format: FormatVersion + 1}
|
||||
if err := s.validate(); !errors.Is(err, ErrUnsupportedFormat) {
|
||||
t.Fatalf("newer format: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotCannotPlantHostEnvironmentOrTheAccountToken(t *testing.T) {
|
||||
provider := func(name, keyEnv string) Item {
|
||||
raw, err := encodeTOML(config.ProviderEntry{Name: name, Kind: "openai", BaseURL: "https://x.example", APIKeyEnv: keyEnv})
|
||||
must(t, err)
|
||||
return Item{ID: itemID(KindProvider, name), Category: CategorySettings, Kind: KindProvider, Name: name, Data: raw}
|
||||
}
|
||||
cats := []Category{CategorySettings, CategorySecrets}
|
||||
cases := map[string]*Snapshot{
|
||||
"unreferenced key": {Format: 1, Categories: cats, Items: []Item{
|
||||
fileItem(t, KindSecret, "REASONIX_ACCOUNTS_URL", secretData{Key: "REASONIX_ACCOUNTS_URL", Value: "https://attacker"}),
|
||||
}},
|
||||
"provider reading the session token": {Format: 1, Categories: cats, Items: []Item{provider("evil", accountTokenKey)}},
|
||||
"proxy key behind a provider": {Format: 1, Categories: cats, Items: []Item{
|
||||
provider("evil", "HTTPS_PROXY"),
|
||||
fileItem(t, KindSecret, "HTTPS_PROXY", secretData{Key: "HTTPS_PROXY", Value: "http://attacker"}),
|
||||
}},
|
||||
"key without the secrets category": {Format: 1, Categories: []Category{CategorySettings}, Items: []Item{
|
||||
provider("ok", "OK_KEY"),
|
||||
fileItem(t, KindSecret, "OK_KEY", secretData{Key: "OK_KEY", Value: "v"}),
|
||||
}},
|
||||
}
|
||||
for name, s := range cases {
|
||||
if err := s.validate(); !errors.Is(err, ErrMalformed) {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointsImportsAndReplacedKeysNeedConsent(t *testing.T) {
|
||||
machine(t, userConfig)
|
||||
_, err := config.SetCredential("WORK_API_KEY", "sk-local")
|
||||
must(t, err)
|
||||
evil, err := encodeTOML(config.ProviderEntry{Name: "evil", Kind: "openai", BaseURL: "https://attacker.example"})
|
||||
must(t, err)
|
||||
s := &Snapshot{Format: 1, Platform: "x/y", Categories: []Category{CategorySettings, CategoryMemory, CategorySecrets}, Items: []Item{
|
||||
{ID: "provider:evil", Category: CategorySettings, Kind: KindProvider, Name: "evil", Data: evil},
|
||||
fileItem(t, KindGeneral, "general", generalData{DefaultModel: "evil/m"}),
|
||||
fileItem(t, KindMemory, "docs/REASONIX.md", memoryData{Root: memoryRootDocs, fileData: fileData{Path: "REASONIX.md", Data: []byte("hi\n@~/.reasonix/.env\n")}}),
|
||||
}}
|
||||
work := collectAll(t, CategorySettings)
|
||||
for _, it := range work.Items {
|
||||
if it.Kind == KindProvider {
|
||||
s.Items = append(s.Items, it)
|
||||
}
|
||||
}
|
||||
s.Items = append(s.Items, fileItem(t, KindSecret, "WORK_API_KEY", secretData{Key: "WORK_API_KEY", Value: "sk-other"}))
|
||||
must(t, s.validate())
|
||||
plan, err := NewPlanner().Preview(s)
|
||||
must(t, err)
|
||||
for id, want := range map[string]string{
|
||||
"provider:evil": ConsentEndpoint, "general:general": ConsentEndpoint,
|
||||
"memory:docs/REASONIX.md": ConsentImports, "secret:WORK_API_KEY": ConsentReplacesSecret,
|
||||
} {
|
||||
row := planRow(t, plan, id)
|
||||
if row.Consent != want || row.Recommended {
|
||||
t.Fatalf("%s: consent %q recommended %v, want %q", id, row.Consent, row.Recommended, want)
|
||||
}
|
||||
}
|
||||
if row := planRow(t, plan, "memory:docs/REASONIX.md"); !strings.Contains(row.Content, "@~/.reasonix/.env") {
|
||||
t.Fatalf("the preview must show the instruction file's body: %q", row.Content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package configbackup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/state/instruction"
|
||||
)
|
||||
|
||||
// consentFor is recomputed at apply time against the machine as it is then;
|
||||
// the preview's answer is only what the person was shown.
|
||||
func consentFor(it Item, s *Snapshot, local *localState) string {
|
||||
if executes(it.Kind) {
|
||||
return ConsentExecutes
|
||||
}
|
||||
switch it.Kind {
|
||||
case KindProvider:
|
||||
var incoming config.ProviderEntry
|
||||
if decodeTOML(it.Data, &incoming) != nil {
|
||||
return ConsentEndpoint
|
||||
}
|
||||
if have, ok := local.providers[it.Name]; ok && endpoints(have) == endpoints(incoming) {
|
||||
return ""
|
||||
}
|
||||
return ConsentEndpoint
|
||||
case KindGeneral:
|
||||
return generalConsent(it, s, local)
|
||||
case KindSecret:
|
||||
var d secretData
|
||||
if json.Unmarshal(it.Data, &d) != nil {
|
||||
return ConsentReplacesSecret
|
||||
}
|
||||
if config.CredentialStored(d.Key) && config.ResolveCredential(d.Key).Value != d.Value {
|
||||
return ConsentReplacesSecret
|
||||
}
|
||||
case KindMemory:
|
||||
var m memoryData
|
||||
if json.Unmarshal(it.Data, &m) == nil && len(instruction.ImportTargets(string(m.Data))) > 0 {
|
||||
return ConsentImports
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// generalConsent covers the default model: pointing it at a provider this
|
||||
// machine does not already reach at the same endpoint sends every
|
||||
// conversation there, whether or not any key goes with it.
|
||||
func generalConsent(it Item, s *Snapshot, local *localState) string {
|
||||
var g generalData
|
||||
if json.Unmarshal(it.Data, &g) != nil {
|
||||
return ConsentEndpoint
|
||||
}
|
||||
name, _, ok := strings.Cut(g.DefaultModel, "/")
|
||||
if !ok || name == "" {
|
||||
return ""
|
||||
}
|
||||
have, known := local.providers[name]
|
||||
if !known {
|
||||
return ConsentEndpoint
|
||||
}
|
||||
for _, other := range s.Items {
|
||||
if other.Kind != KindProvider || other.Name != name {
|
||||
continue
|
||||
}
|
||||
var incoming config.ProviderEntry
|
||||
if decodeTOML(other.Data, &incoming) != nil || endpoints(have) != endpoints(incoming) {
|
||||
return ConsentEndpoint
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func endpoints(p config.ProviderEntry) string {
|
||||
return strings.Join([]string{p.BaseURL, p.ChatURL, p.RequestURL, p.ModelsURL, p.BalanceURL, p.APIKeyEnv}, "\x00")
|
||||
}
|
||||
|
||||
// Environment names a restored key may never take, and a restored provider
|
||||
// may never read its key from. Setting one rewrites how this process reaches
|
||||
// the network or loads code, and the account token is this machine's login.
|
||||
var deniedKeyNames = []string{
|
||||
"PATH", "HOME", "USERPROFILE", "SHELL", "COMSPEC", "TMPDIR", "TEMP", "TMP",
|
||||
"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY",
|
||||
"SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_OPTIONS", "NODE_EXTRA_CA_CERTS",
|
||||
"REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "PYTHONPATH", "PYTHONSTARTUP",
|
||||
"GIT_SSH_COMMAND", "GIT_CONFIG_GLOBAL", "BASH_ENV", "ENV",
|
||||
}
|
||||
|
||||
var deniedKeyPrefixes = []string{"REASONIX_", "LD_", "DYLD_", "GIT_"}
|
||||
|
||||
func deniedKey(key string) bool {
|
||||
upper := strings.ToUpper(strings.TrimSpace(key))
|
||||
if slices.Contains(deniedKeyNames, upper) {
|
||||
return true
|
||||
}
|
||||
return slices.ContainsFunc(deniedKeyPrefixes, func(p string) bool { return strings.HasPrefix(upper, p) })
|
||||
}
|
||||
|
||||
// validateRestorable rejects a snapshot this build would never have written:
|
||||
// a unit whose payload names something other than its item, a key no carried
|
||||
// provider reads, or a key name that steers the host rather than a model.
|
||||
func (s *Snapshot) validateRestorable() error {
|
||||
readers := map[string]bool{}
|
||||
for _, it := range s.Items {
|
||||
switch it.Kind {
|
||||
case KindProvider:
|
||||
var p config.ProviderEntry
|
||||
if err := decodeTOML(it.Data, &p); err != nil || p.Name != it.Name {
|
||||
return fmt.Errorf("%w: provider %q", ErrMalformed, it.Name)
|
||||
}
|
||||
if key := strings.TrimSpace(p.APIKeyEnv); key != "" {
|
||||
if deniedKey(key) {
|
||||
return fmt.Errorf("%w: provider %q reads %s", ErrMalformed, it.Name, key)
|
||||
}
|
||||
readers[key] = true
|
||||
}
|
||||
case KindMCP:
|
||||
var p config.PluginEntry
|
||||
if err := decodeTOML(it.Data, &p); err != nil || p.Name != it.Name {
|
||||
return fmt.Errorf("%w: MCP server %q", ErrMalformed, it.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, it := range s.Items {
|
||||
if it.Kind != KindSecret {
|
||||
continue
|
||||
}
|
||||
var d secretData
|
||||
if err := json.Unmarshal(it.Data, &d); err != nil || d.Key != it.Name || !readers[d.Key] || deniedKey(d.Key) ||
|
||||
!slices.Contains(s.Categories, CategorySecrets) {
|
||||
return fmt.Errorf("%w: key %q", ErrMalformed, it.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package configbackup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
// maxShownContent bounds the file body a preview carries for reading.
|
||||
const maxShownContent = 16 << 10
|
||||
|
||||
// describe is the line a person decides on: the command that will run, the
|
||||
// endpoint a key goes to, the file that will be written.
|
||||
func describe(it Item) (string, []PathRef) {
|
||||
switch it.Kind {
|
||||
case KindProvider:
|
||||
var p config.ProviderEntry
|
||||
if decodeTOML(it.Data, &p) == nil {
|
||||
return strings.TrimSpace(p.Kind + " " + firstNonEmpty(p.RequestURL, p.BaseURL, p.ChatURL)), nil
|
||||
}
|
||||
case KindMCP:
|
||||
var p config.PluginEntry
|
||||
if decodeTOML(it.Data, &p) == nil {
|
||||
if strings.TrimSpace(p.Command) == "" {
|
||||
return p.URL, nil
|
||||
}
|
||||
line := strings.TrimSpace(p.Command + " " + strings.Join(p.Args, " "))
|
||||
return line, absPaths(append([]string{p.Command}, p.Args...)...)
|
||||
}
|
||||
case KindHook:
|
||||
var h hookData
|
||||
if json.Unmarshal(it.Data, &h) == nil {
|
||||
return h.Hook.Command, append(commandPaths(h.Hook.Command), absPaths(h.Hook.Cwd, h.Hook.ContextFile)...)
|
||||
}
|
||||
case KindStatusline:
|
||||
var s statuslineData
|
||||
if json.Unmarshal(it.Data, &s) == nil {
|
||||
return s.Command, commandPaths(s.Command)
|
||||
}
|
||||
case KindPlugin:
|
||||
var p pluginData
|
||||
if json.Unmarshal(it.Data, &p) == nil {
|
||||
return strings.TrimSpace(p.Source + " " + p.Version), absPaths(p.Source)
|
||||
}
|
||||
case KindGeneral:
|
||||
var g generalData
|
||||
if json.Unmarshal(it.Data, &g) == nil {
|
||||
return g.DefaultModel, nil
|
||||
}
|
||||
case KindSecret:
|
||||
return "••••••", nil
|
||||
case KindMemory:
|
||||
var m memoryData
|
||||
if json.Unmarshal(it.Data, &m) == nil {
|
||||
return m.Path, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// details is everything else the item will write that changes what runs or
|
||||
// where data goes, so consent is given to the whole of it, not its first line.
|
||||
func details(it Item) ([]string, string) {
|
||||
switch it.Kind {
|
||||
case KindProvider:
|
||||
var p config.ProviderEntry
|
||||
if decodeTOML(it.Data, &p) != nil {
|
||||
return nil, ""
|
||||
}
|
||||
out := labelled("base_url", p.BaseURL, "request_url", p.RequestURL, "chat_url", p.ChatURL,
|
||||
"models_url", p.ModelsURL, "balance_url", p.BalanceURL, "api_key_env", p.APIKeyEnv)
|
||||
return append(out, pairs("header", p.Headers)...), ""
|
||||
case KindMCP:
|
||||
var p config.PluginEntry
|
||||
if decodeTOML(it.Data, &p) != nil {
|
||||
return nil, ""
|
||||
}
|
||||
out := labelled("type", p.Type, "url", p.URL)
|
||||
return append(append(out, pairs("env", p.Env)...), pairs("header", p.Headers)...), ""
|
||||
case KindHook:
|
||||
var h hookData
|
||||
if json.Unmarshal(it.Data, &h) != nil {
|
||||
return nil, ""
|
||||
}
|
||||
out := labelled("event", h.Event, "match", h.Hook.Match, "cwd", h.Hook.Cwd, "contextFile", h.Hook.ContextFile)
|
||||
return append(out, pairs("env", h.Hook.Env)...), ""
|
||||
case KindMemory:
|
||||
var m memoryData
|
||||
if json.Unmarshal(it.Data, &m) != nil {
|
||||
return nil, ""
|
||||
}
|
||||
body := string(m.Data)
|
||||
if len(body) > maxShownContent {
|
||||
body = body[:maxShownContent]
|
||||
}
|
||||
return nil, body
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func labelled(kv ...string) []string {
|
||||
var out []string
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
if strings.TrimSpace(kv[i+1]) != "" {
|
||||
out = append(out, kv[i]+": "+kv[i+1])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pairs(label string, m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
out := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, label+": "+k+"="+m[k])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Package configbackup turns a user's global Reasonix setup into a snapshot
|
||||
// that can leave the machine, and puts a chosen part of one back.
|
||||
//
|
||||
// A snapshot is a list of items, each owned by one category the user ticked
|
||||
// at export: settings (providers, default model, interface), extensions
|
||||
// (skills, plugin references, MCP servers), memory (user-level standing
|
||||
// instructions and global facts), automation (hooks, the status line) and
|
||||
// secrets (stored API keys, off unless asked for). Files travel as paths
|
||||
// relative to a named root, never as this machine's absolute paths.
|
||||
//
|
||||
// The snapshot is sealed before it leaves: gzip, then XChaCha20-Poly1305 under
|
||||
// a key derived from the user's passphrase with Argon2id. The envelope header
|
||||
// carries the KDF parameters and is authenticated as associated data, so a
|
||||
// server that stores the envelope sees only its size.
|
||||
//
|
||||
// Restoring is two steps. Plan decrypts, compares every item with what this
|
||||
// machine has and caches the snapshot under a plan id; Apply takes that id,
|
||||
// the items the user selected and, separately, the items whose consent the
|
||||
// user gave. An item that runs code here (a hook, the status line, an MCP
|
||||
// server) or that would send a stored key somewhere new needs consent, and
|
||||
// Apply recomputes that requirement against the machine at apply time rather
|
||||
// than trusting the plan. Plugins are never installed here: they come back as
|
||||
// references for the ordinary plugin install flow and its own approval.
|
||||
//
|
||||
// Security posture — approval mode, permissions, sandbox, trust decisions —
|
||||
// is deliberately not a category: restoring one would widen what the agent
|
||||
// may do without the approval that widening normally takes.
|
||||
package configbackup
|
||||
@@ -0,0 +1,151 @@
|
||||
package configbackup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
// MaxEnvelopeBytes is the largest sealed backup this build produces or reads.
|
||||
// The accounts service enforces the same bound on what it stores.
|
||||
const MaxEnvelopeBytes = 4 << 20
|
||||
|
||||
// MinPassphraseRunes is the shortest passphrase Seal accepts. Argon2id slows a
|
||||
// guess down; it cannot make a four-letter word safe.
|
||||
const MinPassphraseRunes = 10
|
||||
|
||||
var envelopeMagic = []byte("RXCB")
|
||||
|
||||
const envelopeVersion = 1
|
||||
|
||||
// RFC 9106's second recommended profile: 64 MiB, three passes. Open accepts a
|
||||
// bounded range so a later build may raise them without orphaning old backups,
|
||||
// while a hostile header cannot make the reader allocate gigabytes.
|
||||
const (
|
||||
kdfTime = 3
|
||||
kdfMemory = 64 * 1024
|
||||
kdfThreads = 4
|
||||
saltBytes = 16
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWeakPassphrase = errors.New("configbackup: passphrase is too short")
|
||||
// ErrCannotDecrypt covers a wrong passphrase and a tampered envelope alike:
|
||||
// an AEAD cannot tell them apart, and guessing would mislead.
|
||||
ErrCannotDecrypt = errors.New("configbackup: wrong passphrase or damaged backup")
|
||||
)
|
||||
|
||||
type envelopeHeader struct {
|
||||
KDF string `json:"kdf"`
|
||||
Time uint32 `json:"t"`
|
||||
Memory uint32 `json:"m"`
|
||||
Threads uint8 `json:"p"`
|
||||
Salt []byte `json:"salt"`
|
||||
AEAD string `json:"aead"`
|
||||
Nonce []byte `json:"nonce"`
|
||||
}
|
||||
|
||||
// Seal encodes and encrypts a snapshot under passphrase.
|
||||
func Seal(s *Snapshot, passphrase string) ([]byte, error) {
|
||||
if utf8.RuneCountInString(passphrase) < MinPassphraseRunes {
|
||||
return nil, ErrWeakPassphrase
|
||||
}
|
||||
plain, err := encode(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h := envelopeHeader{
|
||||
KDF: "argon2id", Time: kdfTime, Memory: kdfMemory, Threads: kdfThreads,
|
||||
Salt: make([]byte, saltBytes), AEAD: "xchacha20poly1305", Nonce: make([]byte, chacha20poly1305.NonceSizeX),
|
||||
}
|
||||
if _, err := rand.Read(h.Salt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := rand.Read(h.Nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix, err := envelopePrefix(h)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := chacha20poly1305.NewX(deriveKey(passphrase, h))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := aead.Seal(prefix, h.Nonce, plain, prefix)
|
||||
if len(out) > MaxEnvelopeBytes {
|
||||
return nil, ErrTooLarge
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Open authenticates and decrypts an envelope and decodes its snapshot.
|
||||
func Open(envelope []byte, passphrase string) (*Snapshot, error) {
|
||||
if len(envelope) > MaxEnvelopeBytes {
|
||||
return nil, ErrTooLarge
|
||||
}
|
||||
h, prefixLen, err := parseHeader(envelope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := chacha20poly1305.NewX(deriveKey(passphrase, h))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := envelope[:prefixLen]
|
||||
plain, err := aead.Open(nil, h.Nonce, envelope[prefixLen:], prefix)
|
||||
if err != nil {
|
||||
return nil, ErrCannotDecrypt
|
||||
}
|
||||
return decode(plain)
|
||||
}
|
||||
|
||||
func deriveKey(passphrase string, h envelopeHeader) []byte {
|
||||
return argon2.IDKey([]byte(passphrase), h.Salt, h.Time, h.Memory, h.Threads, chacha20poly1305.KeySize)
|
||||
}
|
||||
|
||||
func envelopePrefix(h envelopeHeader) ([]byte, error) {
|
||||
header, err := json.Marshal(h)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
buf.Write(envelopeMagic)
|
||||
buf.WriteByte(envelopeVersion)
|
||||
_ = binary.Write(&buf, binary.BigEndian, uint16(len(header)))
|
||||
buf.Write(header)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func parseHeader(envelope []byte) (envelopeHeader, int, error) {
|
||||
var h envelopeHeader
|
||||
fixed := len(envelopeMagic) + 3
|
||||
if len(envelope) < fixed || !bytes.Equal(envelope[:len(envelopeMagic)], envelopeMagic) {
|
||||
return h, 0, fmt.Errorf("%w: not a Reasonix backup", ErrMalformed)
|
||||
}
|
||||
if envelope[len(envelopeMagic)] != envelopeVersion {
|
||||
return h, 0, ErrUnsupportedFormat
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(envelope[len(envelopeMagic)+1 : fixed]))
|
||||
if len(envelope) < fixed+n {
|
||||
return h, 0, fmt.Errorf("%w: truncated header", ErrMalformed)
|
||||
}
|
||||
if err := json.Unmarshal(envelope[fixed:fixed+n], &h); err != nil {
|
||||
return h, 0, fmt.Errorf("%w: %w", ErrMalformed, err)
|
||||
}
|
||||
if h.KDF != "argon2id" || h.AEAD != "xchacha20poly1305" {
|
||||
return h, 0, ErrUnsupportedFormat
|
||||
}
|
||||
if h.Time < 1 || h.Time > 10 || h.Memory < 8*1024 || h.Memory > 256*1024 || h.Threads < 1 || h.Threads > 16 ||
|
||||
len(h.Salt) < saltBytes || len(h.Salt) > 64 || len(h.Nonce) != chacha20poly1305.NonceSizeX {
|
||||
return h, 0, fmt.Errorf("%w: parameters out of range", ErrMalformed)
|
||||
}
|
||||
return h, fixed + n, nil
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package configbackup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/ext/hook"
|
||||
"reasonix/internal/ext/pluginpkg"
|
||||
)
|
||||
|
||||
// The payload of each kind. Config-owned kinds carry TOML under the config
|
||||
// file's own keys, so the snapshot schema moves with the config schema instead
|
||||
// of restating it.
|
||||
type (
|
||||
tomlData struct {
|
||||
TOML string `json:"toml"`
|
||||
}
|
||||
generalData struct {
|
||||
DefaultModel string `json:"defaultModel,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
fileData struct {
|
||||
Path string `json:"path"`
|
||||
Data []byte `json:"data"`
|
||||
}
|
||||
skillData struct {
|
||||
Files []fileData `json:"files"`
|
||||
}
|
||||
memoryData struct {
|
||||
Root string `json:"root"`
|
||||
fileData
|
||||
}
|
||||
pluginData struct {
|
||||
Source string `json:"source"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
}
|
||||
hookData struct {
|
||||
Event string `json:"event"`
|
||||
Hook hook.HookConfig `json:"hook"`
|
||||
}
|
||||
statuslineData struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
secretData struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
)
|
||||
|
||||
// interfaceSettings is the part of [ui] and [desktop] that describes how the
|
||||
// app looks. Machine facts (editor command, pinned version, wallpaper file) and
|
||||
// posture (default approval mode) are left out on purpose.
|
||||
type interfaceSettings struct {
|
||||
UI config.UIConfig `toml:"ui"`
|
||||
Desktop desktopLook `toml:"desktop"`
|
||||
}
|
||||
|
||||
type desktopLook struct {
|
||||
Language string `toml:"language"`
|
||||
Theme string `toml:"theme"`
|
||||
ThemeStyle string `toml:"theme_style"`
|
||||
ThemePack string `toml:"theme_pack"`
|
||||
TerminalTheme string `toml:"terminal_theme"`
|
||||
CloseBehavior string `toml:"close_behavior"`
|
||||
Tray string `toml:"tray"`
|
||||
DisplayMode string `toml:"display_mode"`
|
||||
StatusBarStyle string `toml:"status_bar_style"`
|
||||
StatusBarItems []string `toml:"status_bar_items"`
|
||||
ReasoningDisplayMode string `toml:"reasoning_display_mode"`
|
||||
ConversationWidth string `toml:"conversation_width"`
|
||||
Zoom float64 `toml:"zoom"`
|
||||
ReadSize float64 `toml:"read_size"`
|
||||
FontUI string `toml:"font_ui"`
|
||||
FontMono string `toml:"font_mono"`
|
||||
WallpaperOpacity float64 `toml:"wallpaper_opacity"`
|
||||
WallpaperDim float64 `toml:"wallpaper_dim"`
|
||||
}
|
||||
|
||||
func lookOf(cfg *config.Config) interfaceSettings {
|
||||
d, a := cfg.Desktop, cfg.Desktop.Appearance
|
||||
return interfaceSettings{UI: cfg.UI, Desktop: desktopLook{
|
||||
Language: d.Language, Theme: d.Theme, ThemeStyle: d.ThemeStyle, ThemePack: d.ThemePack,
|
||||
TerminalTheme: d.TerminalTheme, CloseBehavior: d.CloseBehavior, Tray: d.Tray,
|
||||
DisplayMode: d.DisplayMode, StatusBarStyle: d.StatusBarStyle, StatusBarItems: d.StatusBarItems,
|
||||
ReasoningDisplayMode: d.ReasoningDisplayMode, ConversationWidth: d.ConversationWidth,
|
||||
Zoom: a.Zoom, ReadSize: a.ReadSize, FontUI: a.FontUI, FontMono: a.FontMono,
|
||||
WallpaperOpacity: a.Wallpaper.Opacity, WallpaperDim: a.Wallpaper.Dim,
|
||||
}}
|
||||
}
|
||||
|
||||
func (s interfaceSettings) applyTo(cfg *config.Config) {
|
||||
cfg.UI = s.UI
|
||||
d, l := &cfg.Desktop, s.Desktop
|
||||
d.Language, d.Theme, d.ThemeStyle, d.ThemePack = l.Language, l.Theme, l.ThemeStyle, l.ThemePack
|
||||
d.TerminalTheme, d.CloseBehavior, d.Tray = l.TerminalTheme, l.CloseBehavior, l.Tray
|
||||
d.DisplayMode, d.StatusBarStyle, d.StatusBarItems = l.DisplayMode, l.StatusBarStyle, l.StatusBarItems
|
||||
d.ReasoningDisplayMode, d.ConversationWidth = l.ReasoningDisplayMode, l.ConversationWidth
|
||||
a := &d.Appearance
|
||||
a.Zoom, a.ReadSize, a.FontUI, a.FontMono = l.Zoom, l.ReadSize, l.FontUI, l.FontMono
|
||||
if a.Wallpaper.File != "" {
|
||||
a.Wallpaper.Opacity, a.Wallpaper.Dim = l.WallpaperOpacity, l.WallpaperDim
|
||||
}
|
||||
}
|
||||
|
||||
func encodeTOML(v any) (json.RawMessage, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := toml.NewEncoder(&buf).Encode(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(tomlData{TOML: buf.String()})
|
||||
}
|
||||
|
||||
func decodeTOML(raw json.RawMessage, v any) error {
|
||||
var d tomlData
|
||||
if err := json.Unmarshal(raw, &d); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := toml.Decode(d.TOML, v)
|
||||
return err
|
||||
}
|
||||
|
||||
// stripSecrets runs the plugin exporter's own credential stripper over the
|
||||
// env and headers of one named unit, so a backup without secrets leaves the
|
||||
// machine exactly as an exported plugin package does.
|
||||
func stripSecrets(scope string, env, headers map[string]string) (map[string]string, map[string]string) {
|
||||
if len(env) == 0 && len(headers) == 0 {
|
||||
return env, headers
|
||||
}
|
||||
doc := map[string]any{"mcpServers": map[string]any{scope: map[string]any{"env": env, "headers": headers}}}
|
||||
raw, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
stripped, _, err := pluginpkg.StripCredentials(raw)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
var back struct {
|
||||
MCPServers map[string]struct {
|
||||
Env map[string]string `json:"env"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
} `json:"mcpServers"`
|
||||
}
|
||||
if json.Unmarshal(stripped, &back) != nil {
|
||||
return nil, nil
|
||||
}
|
||||
unit := back.MCPServers[scope]
|
||||
return unit.Env, unit.Headers
|
||||
}
|
||||
|
||||
// keepLocalSecrets undoes a strip on the restoring side: a placeholder that
|
||||
// stands for a value this machine already holds must not overwrite it.
|
||||
func keepLocalSecrets(restored, local map[string]string) map[string]string {
|
||||
for k, v := range restored {
|
||||
if isPlaceholder(v) {
|
||||
if have, ok := local[k]; ok {
|
||||
restored[k] = have
|
||||
}
|
||||
}
|
||||
}
|
||||
return restored
|
||||
}
|
||||
|
||||
func isPlaceholder(v string) bool {
|
||||
v = strings.TrimSpace(v)
|
||||
return strings.HasPrefix(v, "${") && strings.HasSuffix(v, "}")
|
||||
}
|
||||
|
||||
// stripURLSecrets drops userinfo, which is where a token hides in a git or
|
||||
// gateway URL. A query string is left alone: it is more often a project id
|
||||
// than a key, and a URL cut short breaks the restore in a way nobody can see.
|
||||
func stripURLSecrets(raw string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u.User == nil || u.Host == "" {
|
||||
return raw
|
||||
}
|
||||
u.User = nil
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func hookName(event string, h hook.HookConfig) string {
|
||||
sum := sha256.Sum256([]byte(event + "\x00" + h.Match + "\x00" + h.Command))
|
||||
return event + "#" + hex.EncodeToString(sum[:6])
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package configbackup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/base/shellparse"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/ext/skill"
|
||||
)
|
||||
|
||||
// Named roots a file item is relative to. The snapshot never carries this
|
||||
// machine's absolute location for them.
|
||||
const (
|
||||
memoryRootDocs = "docs"
|
||||
memoryRootFacts = "facts"
|
||||
)
|
||||
|
||||
// ErrUnsafePath is a file item whose path would land outside its root.
|
||||
var ErrUnsafePath = errors.New("configbackup: file path escapes its root")
|
||||
|
||||
func skillsRoot() string { return filepath.Join(config.ReasonixHomeDir(), skill.SkillsDirname) }
|
||||
|
||||
func memoryDocsRoot() string { return config.MemoryUserDir() }
|
||||
|
||||
func memoryFactsRoot() string {
|
||||
if dir := config.MemoryUserDir(); dir != "" {
|
||||
return filepath.Join(dir, "memory", "global")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func memoryRoot(name string) string {
|
||||
switch name {
|
||||
case memoryRootDocs:
|
||||
return memoryDocsRoot()
|
||||
case memoryRootFacts:
|
||||
return memoryFactsRoot()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// validSegment admits one path segment a restore may create. Colons and
|
||||
// backslashes are refused on every platform: on Windows they name a drive, a
|
||||
// stream or a separator, and a backup written on Linux may be restored there.
|
||||
func validSegment(s string) bool {
|
||||
if s == "" || s == "." || s == ".." || strings.ContainsAny(s, `/\:`+"\x00") {
|
||||
return false
|
||||
}
|
||||
return strings.TrimRight(s, ". ") == s
|
||||
}
|
||||
|
||||
// safeJoin resolves a slash-separated relative path under root and refuses
|
||||
// anything that would leave it, including through a symlink already on disk.
|
||||
func safeJoin(root, rel string) (string, error) {
|
||||
if root == "" || rel == "" || strings.HasPrefix(rel, "/") {
|
||||
return "", fmt.Errorf("%w: %q", ErrUnsafePath, rel)
|
||||
}
|
||||
segments := strings.Split(rel, "/")
|
||||
if !slices.ContainsFunc(segments, func(s string) bool { return !validSegment(s) }) {
|
||||
cur := root
|
||||
for i, seg := range segments {
|
||||
cur = filepath.Join(cur, seg)
|
||||
info, err := os.Lstat(cur)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 || (i < len(segments)-1 && !info.IsDir()) {
|
||||
return "", fmt.Errorf("%w: %q crosses a link or a file", ErrUnsafePath, rel)
|
||||
}
|
||||
}
|
||||
return filepath.Join(append([]string{root}, segments...)...), nil
|
||||
}
|
||||
return "", fmt.Errorf("%w: %q", ErrUnsafePath, rel)
|
||||
}
|
||||
|
||||
// PathRef is an absolute path an item names, and whether it exists here.
|
||||
type PathRef struct {
|
||||
Path string `json:"path"`
|
||||
Exists bool `json:"exists"`
|
||||
}
|
||||
|
||||
// commandPaths lists the absolute paths among a shell command's static words.
|
||||
// A command the parser cannot read statically reports nothing: this is a hint
|
||||
// shown next to the command, never the gate, and the command itself is shown.
|
||||
func commandPaths(command string) []PathRef {
|
||||
leaves, ok := shellparse.CompoundLeafCommands(command)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var words []string
|
||||
for _, argv := range leaves {
|
||||
words = append(words, argv...)
|
||||
}
|
||||
return absPaths(words...)
|
||||
}
|
||||
|
||||
// absPaths reports the words that are absolute paths on either family of
|
||||
// platform, since a backup made on one may be restored on the other.
|
||||
func absPaths(words ...string) []PathRef {
|
||||
var out []PathRef
|
||||
seen := map[string]bool{}
|
||||
for _, w := range words {
|
||||
if seen[w] || !isAbsAnywhere(w) {
|
||||
continue
|
||||
}
|
||||
seen[w] = true
|
||||
_, err := os.Stat(w)
|
||||
out = append(out, PathRef{Path: w, Exists: err == nil})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isAbsAnywhere(p string) bool {
|
||||
if strings.HasPrefix(p, "/") && len(p) > 1 && !strings.HasPrefix(p, "//") {
|
||||
return true
|
||||
}
|
||||
return len(p) >= 3 && p[1] == ':' && (p[2] == '\\' || p[2] == '/') &&
|
||||
((p[0] >= 'A' && p[0] <= 'Z') || (p[0] >= 'a' && p[0] <= 'z'))
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package configbackup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
// Item status against this machine.
|
||||
const (
|
||||
StatusNew = "new"
|
||||
StatusChanged = "changed"
|
||||
StatusSame = "same"
|
||||
// StatusInstall is a plugin: it comes back through the plugin install flow.
|
||||
StatusInstall = "install"
|
||||
)
|
||||
|
||||
// Why an item needs its own consent before it is written.
|
||||
const (
|
||||
ConsentExecutes = "executes"
|
||||
// ConsentEndpoint is a provider, or a default model, that would send
|
||||
// conversations and a stored key to an endpoint this machine does not use.
|
||||
ConsentEndpoint = "endpoint"
|
||||
// ConsentReplacesSecret overwrites a key already stored here.
|
||||
ConsentReplacesSecret = "replaces_secret"
|
||||
// ConsentImports is a standing-instruction file that imports other files.
|
||||
ConsentImports = "imports"
|
||||
)
|
||||
|
||||
// PlanItem is one row of the restore preview.
|
||||
type PlanItem struct {
|
||||
ID string `json:"id"`
|
||||
Category Category `json:"category"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Consent string `json:"consent,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Previous string `json:"previous,omitempty"`
|
||||
Paths []PathRef `json:"paths,omitempty"`
|
||||
CrossPlatform bool `json:"crossPlatform,omitempty"`
|
||||
Files int `json:"files,omitempty"`
|
||||
Details []string `json:"details,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
// Recommended is the default tick: a change that is safe to take as-is.
|
||||
Recommended bool `json:"recommended"`
|
||||
}
|
||||
|
||||
// Plan is the preview of one decrypted backup.
|
||||
type Plan struct {
|
||||
ID string `json:"planId"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
AppVersion string `json:"appVersion,omitempty"`
|
||||
Platform string `json:"platform"`
|
||||
SamePlatform bool `json:"samePlatform"`
|
||||
Categories []Category `json:"categories"`
|
||||
Items []PlanItem `json:"items"`
|
||||
Omitted []Omission `json:"omitted,omitempty"`
|
||||
}
|
||||
|
||||
// ErrPlanExpired is an apply whose plan this process no longer holds.
|
||||
var ErrPlanExpired = errors.New("configbackup: restore preview expired; open the backup again")
|
||||
|
||||
const (
|
||||
planTTL = 15 * time.Minute
|
||||
maxHeldPlan = 4
|
||||
)
|
||||
|
||||
// Planner holds decrypted snapshots between preview and apply, so the apply
|
||||
// acts on exactly what was previewed and the passphrase is asked for once.
|
||||
type Planner struct {
|
||||
mu sync.Mutex
|
||||
plans map[string]heldPlan
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type heldPlan struct {
|
||||
snapshot *Snapshot
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// NewPlanner returns an empty planner.
|
||||
func NewPlanner() *Planner { return &Planner{plans: map[string]heldPlan{}, now: time.Now} }
|
||||
|
||||
// Preview compares a snapshot with this machine and holds it for Apply.
|
||||
func (p *Planner) Preview(s *Snapshot) (*Plan, error) {
|
||||
local, err := collectLocal(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := newPlanID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plan := &Plan{
|
||||
ID: id, CreatedAt: s.CreatedAt, AppVersion: s.AppVersion, Platform: s.Platform,
|
||||
SamePlatform: samePlatform(s.Platform), Categories: s.Categories, Omitted: s.Omitted,
|
||||
}
|
||||
for _, it := range s.Items {
|
||||
plan.Items = append(plan.Items, planItem(it, s, local, plan.SamePlatform))
|
||||
}
|
||||
p.hold(id, s)
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func (p *Planner) hold(id string, s *Snapshot) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
now := p.now()
|
||||
for k, h := range p.plans {
|
||||
if now.After(h.expires) {
|
||||
delete(p.plans, k)
|
||||
}
|
||||
}
|
||||
for len(p.plans) >= maxHeldPlan {
|
||||
oldest := ""
|
||||
for k, h := range p.plans {
|
||||
if oldest == "" || h.expires.Before(p.plans[oldest].expires) {
|
||||
oldest = k
|
||||
}
|
||||
}
|
||||
delete(p.plans, oldest)
|
||||
}
|
||||
p.plans[id] = heldPlan{snapshot: s, expires: now.Add(planTTL)}
|
||||
}
|
||||
|
||||
// take removes a plan: one preview backs one apply, so a replayed request
|
||||
// cannot write the same items twice.
|
||||
func (p *Planner) take(id string) (*Snapshot, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
h, ok := p.plans[id]
|
||||
delete(p.plans, id)
|
||||
if !ok || p.now().After(h.expires) {
|
||||
return nil, ErrPlanExpired
|
||||
}
|
||||
return h.snapshot, nil
|
||||
}
|
||||
|
||||
func newPlanID() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func samePlatform(platform string) bool {
|
||||
goos, _, _ := strings.Cut(platform, "/")
|
||||
return goos == runtime.GOOS
|
||||
}
|
||||
|
||||
// localState is this machine's view for the same items, keyed by item ID.
|
||||
type localState struct {
|
||||
items map[string]Item
|
||||
providers map[string]config.ProviderEntry
|
||||
}
|
||||
|
||||
func collectLocal(s *Snapshot) (*localState, error) {
|
||||
c := newCollector(slices.Contains(s.Categories, CategorySecrets))
|
||||
if err := c.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, cat := range Categories() {
|
||||
if err := c.collect(cat.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
st := &localState{items: map[string]Item{}, providers: map[string]config.ProviderEntry{}}
|
||||
for _, it := range c.items {
|
||||
st.items[it.ID] = it
|
||||
}
|
||||
for _, p := range c.cfg.Providers {
|
||||
st.providers[p.Name] = p
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func planItem(it Item, s *Snapshot, local *localState, same bool) PlanItem {
|
||||
row := PlanItem{ID: it.ID, Category: it.Category, Kind: it.Kind, Name: it.Name}
|
||||
row.Summary, row.Paths = describe(it)
|
||||
row.Details, row.Content = details(it)
|
||||
if it.Kind == KindSkill {
|
||||
var sk skillData
|
||||
if json.Unmarshal(it.Data, &sk) == nil {
|
||||
row.Files = len(sk.Files)
|
||||
}
|
||||
}
|
||||
have, exists := local.items[it.ID]
|
||||
switch {
|
||||
case it.Kind == KindPlugin:
|
||||
row.Status = StatusInstall
|
||||
if exists {
|
||||
row.Status = StatusSame
|
||||
}
|
||||
case !exists:
|
||||
row.Status = StatusNew
|
||||
case bytes.Equal(canonical(have.Data), canonical(it.Data)):
|
||||
row.Status = StatusSame
|
||||
default:
|
||||
row.Status = StatusChanged
|
||||
row.Previous, _ = describe(have)
|
||||
}
|
||||
row.Consent = consentFor(it, s, local)
|
||||
row.CrossPlatform = !same && executes(it.Kind)
|
||||
missing := slices.ContainsFunc(row.Paths, func(p PathRef) bool { return !p.Exists })
|
||||
row.Recommended = row.Status != StatusSame && row.Consent == "" && !missing && !row.CrossPlatform
|
||||
return row
|
||||
}
|
||||
|
||||
func canonical(raw json.RawMessage) []byte {
|
||||
var v any
|
||||
if json.Unmarshal(raw, &v) != nil {
|
||||
return raw
|
||||
}
|
||||
out, _ := json.Marshal(v)
|
||||
return out
|
||||
}
|
||||
|
||||
func executes(kind string) bool {
|
||||
return kind == KindHook || kind == KindStatusline || kind == KindMCP
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package configbackup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FormatVersion is the snapshot schema this build writes. A reader refuses a
|
||||
// newer one rather than guessing at fields it does not know.
|
||||
const FormatVersion = 1
|
||||
|
||||
// Category is one box the user ticks at export.
|
||||
type Category string
|
||||
|
||||
const (
|
||||
CategorySettings Category = "settings"
|
||||
CategoryExtensions Category = "extensions"
|
||||
CategoryMemory Category = "memory"
|
||||
CategoryAutomation Category = "automation"
|
||||
CategorySecrets Category = "secrets"
|
||||
)
|
||||
|
||||
// CategoryInfo is what an export form needs to draw one box.
|
||||
type CategoryInfo struct {
|
||||
ID Category `json:"id"`
|
||||
DefaultOn bool `json:"defaultOn"`
|
||||
// Consent means restoring its items asks per item, whatever the box said.
|
||||
Consent bool `json:"consent"`
|
||||
}
|
||||
|
||||
// Categories lists every category in display order.
|
||||
func Categories() []CategoryInfo {
|
||||
return []CategoryInfo{
|
||||
{ID: CategorySettings, DefaultOn: true},
|
||||
{ID: CategoryExtensions, DefaultOn: true},
|
||||
{ID: CategoryMemory, DefaultOn: true},
|
||||
{ID: CategoryAutomation, DefaultOn: true, Consent: true},
|
||||
{ID: CategorySecrets},
|
||||
}
|
||||
}
|
||||
|
||||
func knownCategory(c Category) bool {
|
||||
return slices.ContainsFunc(Categories(), func(info CategoryInfo) bool { return info.ID == c })
|
||||
}
|
||||
|
||||
// Item kinds. The kind decides how Data is read and where it is written back.
|
||||
const (
|
||||
KindGeneral = "general"
|
||||
KindProvider = "provider"
|
||||
KindInterface = "interface"
|
||||
KindSkill = "skill"
|
||||
KindPlugin = "plugin"
|
||||
KindMCP = "mcp"
|
||||
KindMemory = "memory"
|
||||
KindHook = "hook"
|
||||
KindStatusline = "statusline"
|
||||
KindSecret = "secret"
|
||||
)
|
||||
|
||||
// Snapshot is the plaintext inside a sealed backup.
|
||||
type Snapshot struct {
|
||||
Format int `json:"format"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
AppVersion string `json:"appVersion,omitempty"`
|
||||
Platform string `json:"platform"`
|
||||
Categories []Category `json:"categories"`
|
||||
Items []Item `json:"items"`
|
||||
Omitted []Omission `json:"omitted,omitempty"`
|
||||
}
|
||||
|
||||
// Item is one restorable unit. ID is "<kind>:<name>" and unique in a snapshot.
|
||||
type Item struct {
|
||||
ID string `json:"id"`
|
||||
Category Category `json:"category"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// Omission records something export left out, so the restoring side can say
|
||||
// what it will not find rather than let it look lost.
|
||||
type Omission struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Omission reasons.
|
||||
const (
|
||||
OmitTooLarge = "too_large"
|
||||
OmitUnreadable = "unreadable"
|
||||
OmitNotFile = "not_regular_file"
|
||||
OmitHidden = "hidden"
|
||||
)
|
||||
|
||||
// Sentinels a caller tells apart. Each carries its own identity so a frontend
|
||||
// maps it to a code instead of reading the message.
|
||||
var (
|
||||
ErrUnsupportedFormat = errors.New("configbackup: snapshot format is newer than this build")
|
||||
ErrMalformed = errors.New("configbackup: snapshot is malformed")
|
||||
ErrTooLarge = errors.New("configbackup: snapshot exceeds the size limit")
|
||||
ErrNoCategories = errors.New("configbackup: no category selected")
|
||||
ErrUnknownCategory = errors.New("configbackup: unknown category")
|
||||
)
|
||||
|
||||
const (
|
||||
maxSnapshotBytes = 32 << 20
|
||||
maxItems = 5000
|
||||
)
|
||||
|
||||
func itemID(kind, name string) string { return kind + ":" + name }
|
||||
|
||||
// encode renders a snapshot as gzipped JSON, the plaintext Seal encrypts.
|
||||
func encode(s *Snapshot) ([]byte, error) {
|
||||
raw, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
if _, err := zw.Write(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// decode is encode's inverse. The decompressed size is bounded because the
|
||||
// bytes came from a network and gzip expands by up to three orders of magnitude.
|
||||
func decode(compressed []byte) (*Snapshot, error) {
|
||||
zr, err := gzip.NewReader(bytes.NewReader(compressed))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrMalformed, err)
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(zr, maxSnapshotBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrMalformed, err)
|
||||
}
|
||||
if len(raw) > maxSnapshotBytes {
|
||||
return nil, ErrTooLarge
|
||||
}
|
||||
var s Snapshot
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrMalformed, err)
|
||||
}
|
||||
if err := s.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (s *Snapshot) validate() error {
|
||||
if s.Format < 1 {
|
||||
return fmt.Errorf("%w: missing format", ErrMalformed)
|
||||
}
|
||||
if s.Format > FormatVersion {
|
||||
return ErrUnsupportedFormat
|
||||
}
|
||||
if len(s.Items) > maxItems {
|
||||
return ErrTooLarge
|
||||
}
|
||||
seen := make(map[string]bool, len(s.Items))
|
||||
for _, it := range s.Items {
|
||||
if it.ID != itemID(it.Kind, it.Name) || seen[it.ID] || !knownCategory(it.Category) {
|
||||
return fmt.Errorf("%w: item %q", ErrMalformed, it.ID)
|
||||
}
|
||||
if categoryOfKind(it.Kind) != it.Category {
|
||||
return fmt.Errorf("%w: item %q is filed under %q", ErrMalformed, it.ID, it.Category)
|
||||
}
|
||||
seen[it.ID] = true
|
||||
}
|
||||
return s.validateRestorable()
|
||||
}
|
||||
|
||||
// categoryOfKind is fixed per kind, so a snapshot cannot file a hook under
|
||||
// settings to dodge the consent automation carries.
|
||||
func categoryOfKind(kind string) Category {
|
||||
switch kind {
|
||||
case KindGeneral, KindProvider, KindInterface:
|
||||
return CategorySettings
|
||||
case KindSkill, KindPlugin, KindMCP:
|
||||
return CategoryExtensions
|
||||
case KindMemory:
|
||||
return CategoryMemory
|
||||
case KindHook, KindStatusline:
|
||||
return CategoryAutomation
|
||||
case KindSecret:
|
||||
return CategorySecrets
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ParseCategories validates a requested set and returns it in display order.
|
||||
func ParseCategories(raw []string) ([]Category, error) {
|
||||
want := map[Category]bool{}
|
||||
for _, r := range raw {
|
||||
c := Category(strings.TrimSpace(r))
|
||||
if !knownCategory(c) {
|
||||
return nil, fmt.Errorf("%w: %q", ErrUnknownCategory, r)
|
||||
}
|
||||
want[c] = true
|
||||
}
|
||||
if want[CategorySecrets] {
|
||||
want[CategorySettings] = true
|
||||
}
|
||||
var out []Category
|
||||
for _, info := range Categories() {
|
||||
if want[info.ID] {
|
||||
out = append(out, info.ID)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, ErrNoCategories
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// backup.go — configuration backups kept in the signed-in account. The kernel
|
||||
// seals and opens them; the accounts service only ever holds the envelope.
|
||||
package serve
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"reasonix/internal/ext/configbackup"
|
||||
"reasonix/internal/platform/account"
|
||||
)
|
||||
|
||||
// One planner per process: the machine being restored is one machine, however
|
||||
// many panes are looking at it.
|
||||
var backupPlanner = configbackup.NewPlanner()
|
||||
|
||||
const maxBackupLabelRunes = 80
|
||||
|
||||
func (s *Server) registerBackupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /backups", s.listBackups)
|
||||
mux.HandleFunc("POST /backups", s.createBackup)
|
||||
mux.HandleFunc("DELETE /backups/{id}", s.deleteBackup)
|
||||
mux.HandleFunc("POST /backups/{id}/preview", s.previewBackup)
|
||||
mux.HandleFunc("POST /backups/apply", s.applyBackup)
|
||||
}
|
||||
|
||||
// backupSession is the gate every backup route shares: the host must allow
|
||||
// account routes and someone must be signed in. There is no local-only mode.
|
||||
func (s *Server) backupSession(w http.ResponseWriter, r *http.Request) (*account.Client, string, bool) {
|
||||
if !s.accountAuthAllowed(w, r) {
|
||||
return nil, "", false
|
||||
}
|
||||
token := account.Token()
|
||||
if token == "" {
|
||||
refuse(w, http.StatusUnauthorized, "backup.signed_out", "sign in to use backups", nil)
|
||||
return nil, "", false
|
||||
}
|
||||
client, err := s.accountClient()
|
||||
if err != nil {
|
||||
saveFailed(w, http.StatusInternalServerError, "backup.cloud_unavailable", err)
|
||||
return nil, "", false
|
||||
}
|
||||
return client, token, true
|
||||
}
|
||||
|
||||
func (s *Server) listBackups(w http.ResponseWriter, r *http.Request) {
|
||||
client, token, ok := s.backupSession(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
backups, limits, err := client.ListBackups(r.Context(), token)
|
||||
if err != nil {
|
||||
refuseBackup(w, err)
|
||||
return
|
||||
}
|
||||
if backups == nil {
|
||||
backups = []account.Backup{}
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
"categories": configbackup.Categories(),
|
||||
"backups": backups,
|
||||
"limits": limits,
|
||||
"minPassphrase": configbackup.MinPassphraseRunes,
|
||||
})
|
||||
}
|
||||
|
||||
type createBackupRequest struct {
|
||||
Label string `json:"label"`
|
||||
Categories []string `json:"categories"`
|
||||
Passphrase string `json:"passphrase"`
|
||||
AppVersion string `json:"appVersion"`
|
||||
}
|
||||
|
||||
func (s *Server) createBackup(w http.ResponseWriter, r *http.Request) {
|
||||
client, token, ok := s.backupSession(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req createBackupRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
badBody(w)
|
||||
return
|
||||
}
|
||||
label := strings.TrimSpace(req.Label)
|
||||
if utf8.RuneCountInString(label) > maxBackupLabelRunes {
|
||||
refuse(w, http.StatusBadRequest, "backup.label_too_long", "the label is too long",
|
||||
map[string]any{"max": maxBackupLabelRunes})
|
||||
return
|
||||
}
|
||||
cats, err := configbackup.ParseCategories(req.Categories)
|
||||
if err != nil {
|
||||
refuseBackup(w, err)
|
||||
return
|
||||
}
|
||||
snap, err := configbackup.Collect(configbackup.CollectOptions{Categories: cats, AppVersion: strings.TrimSpace(req.AppVersion)})
|
||||
if err != nil {
|
||||
saveFailed(w, http.StatusInternalServerError, "backup.collect_failed", err)
|
||||
return
|
||||
}
|
||||
envelope, err := configbackup.Seal(snap, req.Passphrase)
|
||||
if err != nil {
|
||||
refuseBackup(w, err)
|
||||
return
|
||||
}
|
||||
names := make([]string, len(cats))
|
||||
for i, c := range cats {
|
||||
names[i] = string(c)
|
||||
}
|
||||
meta := account.BackupUpload{
|
||||
Label: label, Format: snap.Format, AppVersion: snap.AppVersion, Platform: snap.Platform, Categories: names,
|
||||
}
|
||||
backup, err := client.UploadBackup(r.Context(), token, meta, envelope)
|
||||
if err != nil {
|
||||
refuseBackup(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"backup": backup, "omitted": snap.Omitted})
|
||||
}
|
||||
|
||||
func (s *Server) deleteBackup(w http.ResponseWriter, r *http.Request) {
|
||||
client, token, ok := s.backupSession(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := client.DeleteBackup(r.Context(), token, r.PathValue("id")); err != nil {
|
||||
refuseBackup(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) previewBackup(w http.ResponseWriter, r *http.Request) {
|
||||
client, token, ok := s.backupSession(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Passphrase string `json:"passphrase"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
badBody(w)
|
||||
return
|
||||
}
|
||||
_, envelope, err := client.DownloadBackup(r.Context(), token, r.PathValue("id"))
|
||||
if err != nil {
|
||||
refuseBackup(w, err)
|
||||
return
|
||||
}
|
||||
snap, err := configbackup.Open(envelope, req.Passphrase)
|
||||
if err != nil {
|
||||
refuseBackup(w, err)
|
||||
return
|
||||
}
|
||||
plan, err := backupPlanner.Preview(snap)
|
||||
if err != nil {
|
||||
saveFailed(w, http.StatusInternalServerError, "backup.preview_failed", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, plan)
|
||||
}
|
||||
|
||||
// applyBackup writes what the person chose. Consent is checked by the kernel
|
||||
// against the machine as it is now; this handler only carries the answer.
|
||||
func (s *Server) applyBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if _, _, ok := s.backupSession(w, r); !ok {
|
||||
return
|
||||
}
|
||||
var req configbackup.ApplyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
badBody(w)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.PlanID) == "" {
|
||||
missingField(w, "planId")
|
||||
return
|
||||
}
|
||||
res, err := backupPlanner.Apply(req)
|
||||
if err != nil {
|
||||
refuseBackup(w, err)
|
||||
return
|
||||
}
|
||||
out := map[string]any{"applied": res.Applied, "plugins": res.Plugins, "failed": res.Failed}
|
||||
if len(res.Applied) > 0 {
|
||||
if err := s.reloadExtensions(r.Context()); err != nil {
|
||||
out["reloadError"] = err.Error()
|
||||
}
|
||||
}
|
||||
writeJSON(w, out)
|
||||
}
|
||||
|
||||
// refuseBackup projects the class the kernel and the account client already
|
||||
// decided. Anything neither names is the accounts service failing, which is a
|
||||
// dependency answer, never a claim that the request was wrong.
|
||||
func refuseBackup(w http.ResponseWriter, err error) {
|
||||
var consent *configbackup.ConsentError
|
||||
switch {
|
||||
case errors.As(err, &consent):
|
||||
refuse(w, http.StatusConflict, "backup.consent_required", err.Error(), map[string]any{"ids": consent.IDs})
|
||||
case errors.Is(err, account.ErrUnauthorized):
|
||||
refuse(w, http.StatusUnauthorized, "backup.signed_out", "sign in to use backups", nil)
|
||||
case errors.Is(err, configbackup.ErrWeakPassphrase):
|
||||
refuse(w, http.StatusBadRequest, "backup.weak_passphrase", err.Error(),
|
||||
map[string]any{"min": configbackup.MinPassphraseRunes})
|
||||
case errors.Is(err, configbackup.ErrNoCategories), errors.Is(err, configbackup.ErrUnknownCategory):
|
||||
refuse(w, http.StatusBadRequest, "backup.bad_categories", err.Error(), nil)
|
||||
case errors.Is(err, configbackup.ErrCannotDecrypt):
|
||||
refuse(w, http.StatusUnprocessableEntity, "backup.cannot_decrypt", err.Error(), nil)
|
||||
case errors.Is(err, configbackup.ErrUnsupportedFormat):
|
||||
refuse(w, http.StatusUnprocessableEntity, "backup.unsupported_format", err.Error(), nil)
|
||||
case errors.Is(err, configbackup.ErrMalformed):
|
||||
refuse(w, http.StatusUnprocessableEntity, "backup.malformed", err.Error(), nil)
|
||||
case errors.Is(err, configbackup.ErrTooLarge), errors.Is(err, account.ErrBackupTooLarge):
|
||||
refuse(w, http.StatusRequestEntityTooLarge, "backup.too_large", err.Error(), nil)
|
||||
case errors.Is(err, configbackup.ErrPlanExpired):
|
||||
refuse(w, http.StatusGone, "backup.plan_expired", err.Error(), nil)
|
||||
case errors.Is(err, configbackup.ErrUnknownItem):
|
||||
refuse(w, http.StatusBadRequest, "backup.unknown_item", err.Error(), nil)
|
||||
case errors.Is(err, account.ErrBackupNotFound):
|
||||
refuse(w, http.StatusNotFound, "backup.not_found", err.Error(), nil)
|
||||
case errors.Is(err, account.ErrBackupLimit):
|
||||
refuse(w, http.StatusConflict, "backup.limit_reached", err.Error(), nil)
|
||||
default:
|
||||
refuse(w, http.StatusBadGateway, "backup.cloud_unavailable", err.Error(), map[string]any{"detail": err.Error()})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
// fakeBackupService stands in for the accounts service's /me/backups routes and
|
||||
// records every envelope it is handed, so a test can check what left the kernel.
|
||||
type fakeBackupService struct {
|
||||
mu sync.Mutex
|
||||
envelopes map[string][]byte
|
||||
uploads [][]byte
|
||||
}
|
||||
|
||||
func (f *fakeBackupService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = io.WriteString(w, `{"error":{"code":"unauthorized","message":"Sign in to continue."}}`)
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/me/backups":
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
f.uploads = append(f.uploads, raw)
|
||||
var body struct {
|
||||
Envelope string `json:"envelope"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
env, _ := base64.StdEncoding.DecodeString(body.Envelope)
|
||||
f.envelopes["b1"] = env
|
||||
_, _ = io.WriteString(w, `{"backup":{"id":"b1","label":"x"}}`)
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/me/backups":
|
||||
_, _ = io.WriteString(w, `{"backups":[{"id":"b1"}],"limits":{"maxCount":10,"maxBytes":4194304}}`)
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/me/backups/"):
|
||||
env, ok := f.envelopes[strings.TrimPrefix(r.URL.Path, "/me/backups/")]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = io.WriteString(w, `{"error":{"code":"backup_not_found","message":"gone"}}`)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"backup": map[string]any{"id": "b1"}, "envelope": env})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func backupServer(t *testing.T, granted bool) (*httptest.Server, *fakeBackupService) {
|
||||
t.Helper()
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", filepath.Join(home, "home"))
|
||||
t.Setenv("REASONIX_STATE_HOME", filepath.Join(home, "state"))
|
||||
fake := &fakeBackupService{envelopes: map[string][]byte{}}
|
||||
upstream := httptest.NewServer(fake)
|
||||
t.Cleanup(upstream.Close)
|
||||
t.Setenv("REASONIX_ACCOUNTS_URL", upstream.URL)
|
||||
t.Setenv("REASONIX_ACCOUNT_TOKEN", "test-token")
|
||||
return accountServer(t, granted), fake
|
||||
}
|
||||
|
||||
func postBackup(t *testing.T, srv *httptest.Server, path string, body any) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(body)
|
||||
resp, err := srv.Client().Post(srv.URL+path, "application/json", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
return resp, out
|
||||
}
|
||||
|
||||
func TestBackupRoutesNeedTheAccountGrantAndASession(t *testing.T) {
|
||||
srv, _ := backupServer(t, false)
|
||||
resp, _ := postBackup(t, srv, "/backups", map[string]any{})
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("without the host grant = %d", resp.StatusCode)
|
||||
}
|
||||
srv, _ = backupServer(t, true)
|
||||
t.Setenv("REASONIX_ACCOUNT_TOKEN", "")
|
||||
resp, out := postBackup(t, srv, "/backups", map[string]any{})
|
||||
if resp.StatusCode != http.StatusUnauthorized || out["code"] != "backup.signed_out" {
|
||||
t.Fatalf("signed out = %d %v", resp.StatusCode, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupUploadIsSealedAndRestoreAsksForConsent(t *testing.T) {
|
||||
srv, fake := backupServer(t, true)
|
||||
cfg := "[statusline]\ncommand = \"echo from-backup\"\n\n[[providers]]\nname = \"p\"\nkind = \"openai\"\nbase_url = \"https://api.example.com/v1\"\nheaders = { X-Secret = \"literal-header-value\" }\n"
|
||||
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(config.UserConfigPath(), []byte(cfg), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, out := postBackup(t, srv, "/backups", map[string]any{
|
||||
"label": "laptop", "categories": []string{"settings", "automation"}, "passphrase": "short",
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest || out["code"] != "backup.weak_passphrase" {
|
||||
t.Fatalf("weak passphrase = %d %v", resp.StatusCode, out)
|
||||
}
|
||||
resp, out = postBackup(t, srv, "/backups", map[string]any{
|
||||
"label": "laptop", "categories": []string{"settings", "automation"}, "passphrase": "a long enough passphrase",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("create = %d %v", resp.StatusCode, out)
|
||||
}
|
||||
for _, leak := range []string{"api.example.com", "echo from-backup", "literal-header-value"} {
|
||||
if bytes.Contains(fake.uploads[0], []byte(leak)) {
|
||||
t.Fatalf("upload carries %q in the clear", leak)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(config.UserConfigPath(), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, out = postBackup(t, srv, "/backups/b1/preview", map[string]any{"passphrase": "not the passphrase"})
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity || out["code"] != "backup.cannot_decrypt" {
|
||||
t.Fatalf("wrong passphrase = %d %v", resp.StatusCode, out)
|
||||
}
|
||||
resp, out = postBackup(t, srv, "/backups/b1/preview", map[string]any{"passphrase": "a long enough passphrase"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("preview = %d %v", resp.StatusCode, out)
|
||||
}
|
||||
planID, _ := out["planId"].(string)
|
||||
resp, out = postBackup(t, srv, "/backups/apply", map[string]any{"planId": planID, "items": []string{"statusline:statusline"}})
|
||||
if resp.StatusCode != http.StatusConflict || out["code"] != "backup.consent_required" {
|
||||
t.Fatalf("apply without consent = %d %v", resp.StatusCode, out)
|
||||
}
|
||||
if got, _ := os.ReadFile(config.UserConfigPath()); bytes.Contains(got, []byte("from-backup")) {
|
||||
t.Fatal("a refused apply wrote the status line")
|
||||
}
|
||||
resp, out = postBackup(t, srv, "/backups/apply", map[string]any{
|
||||
"planId": planID, "items": []string{"statusline:statusline"}, "consented": []string{"statusline:statusline"},
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("apply = %d %v", resp.StatusCode, out)
|
||||
}
|
||||
if got, _ := os.ReadFile(config.UserConfigPath()); !bytes.Contains(got, []byte("echo from-backup")) {
|
||||
t.Fatalf("status line not restored: %s", got)
|
||||
}
|
||||
resp, out = postBackup(t, srv, "/backups/b9/preview", map[string]any{"passphrase": "a long enough passphrase"})
|
||||
if resp.StatusCode != http.StatusNotFound || out["code"] != "backup.not_found" {
|
||||
t.Fatalf("missing backup = %d %v", resp.StatusCode, out)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ func (s *Server) routes() http.Handler {
|
||||
mux.HandleFunc("POST /submit", s.submit)
|
||||
s.registerExtensionRoutes(mux)
|
||||
s.registerPluginRoutes(mux)
|
||||
s.registerBackupRoutes(mux)
|
||||
s.registerThemeRoutes(mux)
|
||||
s.registerAppearanceRoutes(mux)
|
||||
s.registerSurfaceRoutes(mux)
|
||||
|
||||
@@ -214,6 +214,10 @@ func (c *Client) Logout(ctx context.Context, token string) error {
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path, token string, body, out any) error {
|
||||
return c.doLimit(ctx, method, path, token, body, out, 1<<20)
|
||||
}
|
||||
|
||||
func (c *Client) doLimit(ctx context.Context, method, path, token string, body, out any, limit int64) error {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
encoded, err := json.Marshal(body)
|
||||
@@ -240,7 +244,7 @@ func (c *Client) do(ctx context.Context, method, path, token string, body, out a
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
payload, err := io.ReadAll(io.LimitReader(resp.Body, limit))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Backup is one stored configuration backup. The service holds the sealed
|
||||
// envelope and this metadata; it never sees what the envelope contains.
|
||||
type Backup struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Format int `json:"format"`
|
||||
AppVersion string `json:"appVersion"`
|
||||
Platform string `json:"platform"`
|
||||
Categories []string `json:"categories"`
|
||||
CiphertextBytes int64 `json:"ciphertextBytes"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// BackupUpload is what the client sends besides the envelope itself.
|
||||
type BackupUpload struct {
|
||||
Label string `json:"label"`
|
||||
Format int `json:"format"`
|
||||
AppVersion string `json:"appVersion"`
|
||||
Platform string `json:"platform"`
|
||||
Categories []string `json:"categories"`
|
||||
}
|
||||
|
||||
// BackupLimits is the per-account quota the service enforces.
|
||||
type BackupLimits struct {
|
||||
MaxCount int `json:"maxCount"`
|
||||
MaxBytes int64 `json:"maxBytes"`
|
||||
}
|
||||
|
||||
// Errors the backup routes answer with, by the service's own code.
|
||||
var (
|
||||
ErrBackupNotFound = errors.New("account: no such backup")
|
||||
ErrBackupLimit = errors.New("account: backup limit reached")
|
||||
ErrBackupTooLarge = errors.New("account: backup is larger than the service accepts")
|
||||
ErrBackupsUnavailable = errors.New("account: backup storage is unavailable")
|
||||
)
|
||||
|
||||
const backupReadLimit = 8 << 20
|
||||
|
||||
func (c *Client) ListBackups(ctx context.Context, token string) ([]Backup, BackupLimits, error) {
|
||||
var out struct {
|
||||
Backups []Backup `json:"backups"`
|
||||
Limits BackupLimits `json:"limits"`
|
||||
}
|
||||
if err := c.do(ctx, http.MethodGet, "/me/backups", token, nil, &out); err != nil {
|
||||
return nil, BackupLimits{}, backupError(err)
|
||||
}
|
||||
return out.Backups, out.Limits, nil
|
||||
}
|
||||
|
||||
func (c *Client) UploadBackup(ctx context.Context, token string, meta BackupUpload, envelope []byte) (*Backup, error) {
|
||||
body := struct {
|
||||
BackupUpload
|
||||
Envelope string `json:"envelope"`
|
||||
}{meta, base64.StdEncoding.EncodeToString(envelope)}
|
||||
var out struct {
|
||||
Backup Backup `json:"backup"`
|
||||
}
|
||||
if err := c.do(ctx, http.MethodPost, "/me/backups", token, body, &out); err != nil {
|
||||
return nil, backupError(err)
|
||||
}
|
||||
return &out.Backup, nil
|
||||
}
|
||||
|
||||
func (c *Client) DownloadBackup(ctx context.Context, token, id string) (*Backup, []byte, error) {
|
||||
var out struct {
|
||||
Backup Backup `json:"backup"`
|
||||
Envelope string `json:"envelope"`
|
||||
}
|
||||
if err := c.doLimit(ctx, http.MethodGet, "/me/backups/"+url.PathEscape(id), token, nil, &out, backupReadLimit); err != nil {
|
||||
return nil, nil, backupError(err)
|
||||
}
|
||||
envelope, err := base64.StdEncoding.DecodeString(strings.TrimSpace(out.Envelope))
|
||||
if err != nil {
|
||||
return nil, nil, errors.New("account: backup download is not base64")
|
||||
}
|
||||
return &out.Backup, envelope, nil
|
||||
}
|
||||
|
||||
func (c *Client) DeleteBackup(ctx context.Context, token, id string) error {
|
||||
return backupError(c.do(ctx, http.MethodDelete, "/me/backups/"+url.PathEscape(id), token, nil, nil))
|
||||
}
|
||||
|
||||
func backupError(err error) error {
|
||||
var apiErr *Error
|
||||
if !errors.As(err, &apiErr) {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case apiErr.Status == http.StatusUnauthorized:
|
||||
return ErrUnauthorized
|
||||
case apiErr.Code == "backup_not_found":
|
||||
return ErrBackupNotFound
|
||||
case apiErr.Code == "backup_limit_reached":
|
||||
return ErrBackupLimit
|
||||
case apiErr.Code == "backup_too_large":
|
||||
return ErrBackupTooLarge
|
||||
case apiErr.Code == "backups_unavailable":
|
||||
return ErrBackupsUnavailable
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -280,6 +280,18 @@ func resolveDocumentImports(body, sourcePath string, boundaries []string, depth
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// ImportTargets lists the import lines of a document body, in order, exactly as
|
||||
// the resolver would read them.
|
||||
func ImportTargets(body string) []string {
|
||||
var out []string
|
||||
for line := range strings.SplitSeq(body, "\n") {
|
||||
if target, ok := parseImportTarget(line); ok {
|
||||
out = append(out, target)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseImportTarget(line string) (string, bool) {
|
||||
t := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(t, "@") || len(t) == 1 || strings.ContainsAny(t, " \t") {
|
||||
|
||||
Reference in new issue
Block a user