@membox-cloud/membox 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +159 -0
- package/dist/src/api/account.d.ts +3 -0
- package/dist/src/api/account.js +3 -0
- package/dist/src/api/client.d.ts +21 -0
- package/dist/src/api/client.js +107 -0
- package/dist/src/api/device-flow.d.ts +9 -0
- package/dist/src/api/device-flow.js +55 -0
- package/dist/src/api/devices.d.ts +10 -0
- package/dist/src/api/devices.js +24 -0
- package/dist/src/api/recovery.d.ts +5 -0
- package/dist/src/api/recovery.js +9 -0
- package/dist/src/api/sync.d.ts +9 -0
- package/dist/src/api/sync.js +22 -0
- package/dist/src/cli/bootstrap.d.ts +37 -0
- package/dist/src/cli/bootstrap.js +326 -0
- package/dist/src/cli/grants.d.ts +8 -0
- package/dist/src/cli/grants.js +76 -0
- package/dist/src/cli/helpers.d.ts +6 -0
- package/dist/src/cli/helpers.js +13 -0
- package/dist/src/cli/passphrase-input.d.ts +11 -0
- package/dist/src/cli/passphrase-input.js +94 -0
- package/dist/src/cli/pause.d.ts +2 -0
- package/dist/src/cli/pause.js +29 -0
- package/dist/src/cli/provisioning.d.ts +3 -0
- package/dist/src/cli/provisioning.js +30 -0
- package/dist/src/cli/pull.d.ts +31 -0
- package/dist/src/cli/pull.js +142 -0
- package/dist/src/cli/restore.d.ts +12 -0
- package/dist/src/cli/restore.js +90 -0
- package/dist/src/cli/setup.d.ts +17 -0
- package/dist/src/cli/setup.js +209 -0
- package/dist/src/cli/status.d.ts +1 -0
- package/dist/src/cli/status.js +31 -0
- package/dist/src/cli/sync.d.ts +4 -0
- package/dist/src/cli/sync.js +124 -0
- package/dist/src/cli/unlock.d.ts +19 -0
- package/dist/src/cli/unlock.js +153 -0
- package/dist/src/config.d.ts +4 -0
- package/dist/src/config.js +12 -0
- package/dist/src/constants.d.ts +23 -0
- package/dist/src/constants.js +27 -0
- package/dist/src/contract-types.d.ts +301 -0
- package/dist/src/contract-types.js +52 -0
- package/dist/src/crypto/aes-gcm.d.ts +29 -0
- package/dist/src/crypto/aes-gcm.js +44 -0
- package/dist/src/crypto/device-keys.d.ts +18 -0
- package/dist/src/crypto/device-keys.js +25 -0
- package/dist/src/crypto/grant.d.ts +29 -0
- package/dist/src/crypto/grant.js +87 -0
- package/dist/src/crypto/kdf.d.ts +16 -0
- package/dist/src/crypto/kdf.js +24 -0
- package/dist/src/crypto/keys.d.ts +14 -0
- package/dist/src/crypto/keys.js +35 -0
- package/dist/src/crypto/manifest.d.ts +25 -0
- package/dist/src/crypto/manifest.js +41 -0
- package/dist/src/crypto/recovery.d.ts +16 -0
- package/dist/src/crypto/recovery.js +94 -0
- package/dist/src/crypto/types.d.ts +34 -0
- package/dist/src/crypto/types.js +1 -0
- package/dist/src/debug-logger.d.ts +32 -0
- package/dist/src/debug-logger.js +108 -0
- package/dist/src/hooks/gateway-lifecycle.d.ts +6 -0
- package/dist/src/hooks/gateway-lifecycle.js +40 -0
- package/dist/src/hooks/prompt-inject.d.ts +7 -0
- package/dist/src/hooks/prompt-inject.js +18 -0
- package/dist/src/store/keychain.d.ts +26 -0
- package/dist/src/store/keychain.js +151 -0
- package/dist/src/store/local-state.d.ts +27 -0
- package/dist/src/store/local-state.js +47 -0
- package/dist/src/store/managed-unlock.d.ts +8 -0
- package/dist/src/store/managed-unlock.js +46 -0
- package/dist/src/store/pending-setup.d.ts +23 -0
- package/dist/src/store/pending-setup.js +32 -0
- package/dist/src/store/session.d.ts +13 -0
- package/dist/src/store/session.js +28 -0
- package/dist/src/sync/auto-sync.d.ts +12 -0
- package/dist/src/sync/auto-sync.js +82 -0
- package/dist/src/sync/conflict.d.ts +24 -0
- package/dist/src/sync/conflict.js +92 -0
- package/dist/src/sync/diff.d.ts +25 -0
- package/dist/src/sync/diff.js +75 -0
- package/dist/src/sync/downloader.d.ts +16 -0
- package/dist/src/sync/downloader.js +73 -0
- package/dist/src/sync/scanner.d.ts +12 -0
- package/dist/src/sync/scanner.js +52 -0
- package/dist/src/sync/state.d.ts +4 -0
- package/dist/src/sync/state.js +22 -0
- package/dist/src/sync/uploader.d.ts +20 -0
- package/dist/src/sync/uploader.js +86 -0
- package/dist/src/tools/grants-approve-pending.d.ts +17 -0
- package/dist/src/tools/grants-approve-pending.js +50 -0
- package/dist/src/tools/pull.d.ts +31 -0
- package/dist/src/tools/pull.js +71 -0
- package/dist/src/tools/restore.d.ts +31 -0
- package/dist/src/tools/restore.js +96 -0
- package/dist/src/tools/result.d.ts +7 -0
- package/dist/src/tools/result.js +6 -0
- package/dist/src/tools/secret-file.d.ts +10 -0
- package/dist/src/tools/secret-file.js +37 -0
- package/dist/src/tools/setup-finish.d.ts +36 -0
- package/dist/src/tools/setup-finish.js +108 -0
- package/dist/src/tools/setup-poll.d.ts +27 -0
- package/dist/src/tools/setup-poll.js +83 -0
- package/dist/src/tools/setup-start.d.ts +18 -0
- package/dist/src/tools/setup-start.js +49 -0
- package/dist/src/tools/status.d.ts +17 -0
- package/dist/src/tools/status.js +40 -0
- package/dist/src/tools/sync.d.ts +17 -0
- package/dist/src/tools/sync.js +49 -0
- package/dist/src/tools/unlock-secret.d.ts +42 -0
- package/dist/src/tools/unlock-secret.js +87 -0
- package/dist/src/tools/unlock.d.ts +25 -0
- package/dist/src/tools/unlock.js +72 -0
- package/openclaw.plugin.json +16 -0
- package/package.json +35 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { writeFile, mkdir, rename } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve, relative, isAbsolute } from "node:path";
|
|
3
|
+
import { vaultSession } from "../store/session.js";
|
|
4
|
+
import { getState } from "../sync/state.js";
|
|
5
|
+
import { computeSyncDiff } from "../sync/diff.js";
|
|
6
|
+
import { scanLocalMemoryFiles } from "../sync/scanner.js";
|
|
7
|
+
import { downloadAndDecrypt } from "../sync/downloader.js";
|
|
8
|
+
import { getSyncChanges } from "../api/sync.js";
|
|
9
|
+
import { updateCursor, updateFileVersion } from "../sync/state.js";
|
|
10
|
+
import { createAuthenticatedClient } from "./helpers.js";
|
|
11
|
+
import { computeSha256Hex } from "../crypto/manifest.js";
|
|
12
|
+
import { resolveConflict } from "../sync/conflict.js";
|
|
13
|
+
import { ensureVaultUnlocked } from "./unlock.js";
|
|
14
|
+
export async function pullAction(options) {
|
|
15
|
+
if (!(await ensureVaultUnlocked({ announceSuccess: true }))) {
|
|
16
|
+
return {
|
|
17
|
+
status: "locked",
|
|
18
|
+
message: "Vault is locked. Unlock it first with `openclaw membox unlock`.",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const amk = vaultSession.getAMK();
|
|
22
|
+
const state = await getState();
|
|
23
|
+
const client = await createAuthenticatedClient(state);
|
|
24
|
+
const remote = await getSyncChanges(client, state.sync_cursor);
|
|
25
|
+
if (remote.changes.length === 0) {
|
|
26
|
+
console.log("Already up to date.");
|
|
27
|
+
return {
|
|
28
|
+
status: "up_to_date",
|
|
29
|
+
cursor: state.sync_cursor,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const localFiles = await scanLocalMemoryFiles(process.cwd());
|
|
33
|
+
const diff = computeSyncDiff(localFiles, remote.changes, state);
|
|
34
|
+
const strategy = options?.onConflict ?? "conflict-copy";
|
|
35
|
+
// Preview mode: show what would happen without executing
|
|
36
|
+
if (options?.preview) {
|
|
37
|
+
// Build objectId → logicalPath lookup from known file versions
|
|
38
|
+
const objectToPath = new Map();
|
|
39
|
+
for (const [path, entry] of Object.entries(state.file_versions)) {
|
|
40
|
+
objectToPath.set(entry.object_id, path);
|
|
41
|
+
}
|
|
42
|
+
console.log("Pull preview:");
|
|
43
|
+
if (diff.toDownload.length > 0) {
|
|
44
|
+
console.log(` ${diff.toDownload.length} file(s) to download:`);
|
|
45
|
+
for (const item of diff.toDownload) {
|
|
46
|
+
const label = item.changeType === "delete" ? "[delete]" : "[upsert]";
|
|
47
|
+
const name = objectToPath.get(item.objectId) ?? item.objectId;
|
|
48
|
+
console.log(` ${label} ${name}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
console.log(" No files to download.");
|
|
53
|
+
}
|
|
54
|
+
if (diff.conflicts.length > 0) {
|
|
55
|
+
console.log(` ${diff.conflicts.length} conflict(s) (strategy: ${strategy}):`);
|
|
56
|
+
for (const c of diff.conflicts) {
|
|
57
|
+
console.log(` - ${c.logicalPath}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
status: "preview",
|
|
62
|
+
cursor: remote.cursor,
|
|
63
|
+
strategy,
|
|
64
|
+
toDownload: diff.toDownload.map((item) => ({
|
|
65
|
+
logicalPath: objectToPath.get(item.objectId) ?? item.objectId,
|
|
66
|
+
objectId: item.objectId,
|
|
67
|
+
version: item.version,
|
|
68
|
+
changeType: item.changeType,
|
|
69
|
+
})),
|
|
70
|
+
conflicts: diff.conflicts.map((item) => ({
|
|
71
|
+
logicalPath: item.logicalPath,
|
|
72
|
+
objectId: item.objectId,
|
|
73
|
+
})),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
// Handle conflicts with chosen strategy
|
|
77
|
+
if (diff.conflicts.length > 0) {
|
|
78
|
+
console.log(`${diff.conflicts.length} conflict(s) detected (strategy: ${strategy}):`);
|
|
79
|
+
for (const c of diff.conflicts) {
|
|
80
|
+
const localFile = localFiles.get(c.logicalPath);
|
|
81
|
+
const existing = state.file_versions[c.logicalPath];
|
|
82
|
+
const result = await resolveConflict({
|
|
83
|
+
logicalPath: c.logicalPath,
|
|
84
|
+
objectId: c.objectId,
|
|
85
|
+
localFile,
|
|
86
|
+
existingVersion: existing?.version,
|
|
87
|
+
}, strategy, {
|
|
88
|
+
amk,
|
|
89
|
+
client,
|
|
90
|
+
userId: state.user_id,
|
|
91
|
+
deviceId: state.device_id,
|
|
92
|
+
workspaceDir: process.cwd(),
|
|
93
|
+
});
|
|
94
|
+
if (result.fileVersionUpdate) {
|
|
95
|
+
await updateFileVersion(result.fileVersionUpdate.path, result.fileVersionUpdate.entry);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
let downloaded = 0;
|
|
100
|
+
const conflictsResolved = diff.conflicts.length;
|
|
101
|
+
const base = process.cwd();
|
|
102
|
+
for (const item of diff.toDownload) {
|
|
103
|
+
if (item.changeType === "delete") {
|
|
104
|
+
console.log(` Tombstone for ${item.objectId} (skip local delete in V0.1)`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
console.log(` Downloading ${item.objectId}...`);
|
|
108
|
+
const result = await downloadAndDecrypt({
|
|
109
|
+
amk,
|
|
110
|
+
objectId: item.objectId,
|
|
111
|
+
client,
|
|
112
|
+
version: item.version,
|
|
113
|
+
});
|
|
114
|
+
// Path traversal guard: ensure logicalPath stays within workspace
|
|
115
|
+
const outPath = resolve(base, result.logicalPath);
|
|
116
|
+
const rel = relative(base, outPath);
|
|
117
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
118
|
+
console.log(` Skipping unsafe path: ${result.logicalPath}`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
122
|
+
// Atomic write: temp file then rename
|
|
123
|
+
const tmp = outPath + ".tmp." + Date.now();
|
|
124
|
+
await writeFile(tmp, result.content);
|
|
125
|
+
await rename(tmp, outPath);
|
|
126
|
+
console.log(` -> ${result.logicalPath}`);
|
|
127
|
+
await updateFileVersion(result.logicalPath, {
|
|
128
|
+
object_id: item.objectId,
|
|
129
|
+
version: result.version,
|
|
130
|
+
content_sha256: computeSha256Hex(result.content),
|
|
131
|
+
});
|
|
132
|
+
downloaded++;
|
|
133
|
+
}
|
|
134
|
+
await updateCursor(remote.cursor);
|
|
135
|
+
console.log(`Pull complete. ${downloaded} file(s) downloaded.`);
|
|
136
|
+
return {
|
|
137
|
+
status: "ok",
|
|
138
|
+
cursor: remote.cursor,
|
|
139
|
+
downloaded,
|
|
140
|
+
conflictsResolved,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { memboxConfig } from "../config.js";
|
|
2
|
+
import { type AuthorizedDeviceContext } from "./bootstrap.js";
|
|
3
|
+
export declare function restoreAuthorizedDeviceFromRecovery(cfg: memboxConfig, auth: AuthorizedDeviceContext, params: {
|
|
4
|
+
recoveryCode: string;
|
|
5
|
+
passphrase: string;
|
|
6
|
+
}): Promise<{
|
|
7
|
+
ok: boolean;
|
|
8
|
+
restored: boolean;
|
|
9
|
+
reason?: "no_recovery_bundle";
|
|
10
|
+
message: string;
|
|
11
|
+
}>;
|
|
12
|
+
export declare function restoreAction(cfg: memboxConfig): Promise<void>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import { createInitialState, writeState } from "../store/local-state.js";
|
|
4
|
+
import { readState } from "../store/local-state.js";
|
|
5
|
+
import { downloadRecoveryBundle, getRecoveryStatus } from "../api/recovery.js";
|
|
6
|
+
import { restoreFromRecoveryBundle } from "../crypto/recovery.js";
|
|
7
|
+
import { computeSha256Hex } from "../crypto/manifest.js";
|
|
8
|
+
import { vaultSession } from "../store/session.js";
|
|
9
|
+
import { pullAction } from "./pull.js";
|
|
10
|
+
import { readPassphraseWithConfirm } from "./passphrase-input.js";
|
|
11
|
+
import { authorizeDevice, clearPendingSetupArtifacts, persistVaultSecrets, } from "./bootstrap.js";
|
|
12
|
+
import { runWithProvisioningRollback } from "./provisioning.js";
|
|
13
|
+
async function readRecoveryCode() {
|
|
14
|
+
const rl = createInterface({ input, output });
|
|
15
|
+
try {
|
|
16
|
+
const code = await rl.question("Recovery code: ");
|
|
17
|
+
return code.trim();
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
rl.close();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function restoreAuthorizedDeviceFromRecovery(cfg, auth, params) {
|
|
24
|
+
const recoveryStatus = await getRecoveryStatus(auth.client);
|
|
25
|
+
if (!recoveryStatus.has_recovery_bundle) {
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
restored: false,
|
|
29
|
+
reason: "no_recovery_bundle",
|
|
30
|
+
message: "No recovery bundle is stored for this account. If another trusted device still has access, run `openclaw membox setup` on this device and approve the grant from the trusted device.",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return runWithProvisioningRollback(auth, async (markProvisioned) => {
|
|
34
|
+
const downloaded = await downloadRecoveryBundle(auth.client);
|
|
35
|
+
const bundleBytes = Buffer.from(downloaded.encrypted_payload_b64, "base64");
|
|
36
|
+
if (computeSha256Hex(bundleBytes) !== downloaded.payload_checksum) {
|
|
37
|
+
throw new Error("Downloaded recovery bundle checksum mismatch.");
|
|
38
|
+
}
|
|
39
|
+
const bundle = JSON.parse(bundleBytes.toString("utf-8"));
|
|
40
|
+
const amk = await restoreFromRecoveryBundle(params.recoveryCode, bundle);
|
|
41
|
+
try {
|
|
42
|
+
await persistVaultSecrets({
|
|
43
|
+
deviceKeys: auth.deviceKeys,
|
|
44
|
+
passphrase: params.passphrase,
|
|
45
|
+
amk,
|
|
46
|
+
refreshToken: auth.tokens.refresh_token,
|
|
47
|
+
});
|
|
48
|
+
const state = createInitialState({
|
|
49
|
+
serverUrl: cfg.serverUrl,
|
|
50
|
+
deviceId: auth.tokens.device_id,
|
|
51
|
+
deviceName: auth.deviceName,
|
|
52
|
+
userId: auth.account.user_id,
|
|
53
|
+
});
|
|
54
|
+
await writeState(state);
|
|
55
|
+
await clearPendingSetupArtifacts();
|
|
56
|
+
markProvisioned();
|
|
57
|
+
vaultSession.unlock(amk);
|
|
58
|
+
console.log("Recovery successful. Pulling encrypted memory...");
|
|
59
|
+
await pullAction({ onConflict: "conflict-copy" });
|
|
60
|
+
console.log("Restore complete! Vault is ready.");
|
|
61
|
+
return {
|
|
62
|
+
ok: true,
|
|
63
|
+
restored: true,
|
|
64
|
+
message: "Restore complete! Vault is ready.",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
amk.fill(0);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export async function restoreAction(cfg) {
|
|
73
|
+
const existing = await readState();
|
|
74
|
+
if (existing?.setup_complete) {
|
|
75
|
+
console.log("Vault already set up. Use `openclaw membox status` to check.");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const auth = await authorizeDevice(cfg);
|
|
79
|
+
console.log("\nRestore this device from your recovery bundle.");
|
|
80
|
+
const recoveryCode = await readRecoveryCode();
|
|
81
|
+
const passphrase = await readPassphraseWithConfirm("New vault passphrase: ");
|
|
82
|
+
const result = await restoreAuthorizedDeviceFromRecovery(cfg, auth, {
|
|
83
|
+
recoveryCode,
|
|
84
|
+
passphrase,
|
|
85
|
+
});
|
|
86
|
+
if (result.reason === "no_recovery_bundle") {
|
|
87
|
+
console.log("No recovery bundle is stored for this account.");
|
|
88
|
+
console.log("If another trusted device still has access, run `openclaw membox setup` on this device and approve the grant from the trusted device.");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { memboxConfig } from "../config.js";
|
|
2
|
+
import type { DeviceSummary } from "../contract-types.js";
|
|
3
|
+
import { authorizeDevice } from "./bootstrap.js";
|
|
4
|
+
export declare function selectGrantCapableDevices(devices: DeviceSummary[], currentDeviceId: string): DeviceSummary[];
|
|
5
|
+
export interface SetupCompletionResult {
|
|
6
|
+
initialMode: "upload" | "pull";
|
|
7
|
+
uploadedFiles: string[];
|
|
8
|
+
downloadedFiles: number;
|
|
9
|
+
recoveryCodeGenerated: boolean;
|
|
10
|
+
managedUnlockEnabled: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function finishAuthorizedSetup(cfg: memboxConfig, auth: Awaited<ReturnType<typeof authorizeDevice>>, params: {
|
|
13
|
+
passphrase: string;
|
|
14
|
+
enableManagedUnlock?: boolean;
|
|
15
|
+
onRecoveryCode?: (recoveryCode: string) => Promise<void>;
|
|
16
|
+
}): Promise<SetupCompletionResult>;
|
|
17
|
+
export declare function setupAction(cfg: memboxConfig): Promise<void>;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { writeState, createInitialState } from "../store/local-state.js";
|
|
2
|
+
import { readState } from "../store/local-state.js";
|
|
3
|
+
import { generateAMK } from "../crypto/keys.js";
|
|
4
|
+
import { generateRecoveryCode, createRecoveryBundle, } from "../crypto/recovery.js";
|
|
5
|
+
import { receiveDeviceGrant } from "../crypto/grant.js";
|
|
6
|
+
import { requestGrant, getGrant, listDevices } from "../api/devices.js";
|
|
7
|
+
import { getRecoveryStatus, uploadRecoveryMaterial, } from "../api/recovery.js";
|
|
8
|
+
import { getSyncStatus } from "../api/sync.js";
|
|
9
|
+
import { readPassphraseWithConfirm } from "./passphrase-input.js";
|
|
10
|
+
import { vaultSession } from "../store/session.js";
|
|
11
|
+
import { enableManagedUnlock } from "../store/managed-unlock.js";
|
|
12
|
+
import { computeSha256Hex } from "../crypto/manifest.js";
|
|
13
|
+
import { scanLocalMemoryFiles } from "../sync/scanner.js";
|
|
14
|
+
import { uploadFile } from "../sync/uploader.js";
|
|
15
|
+
import { updateFileVersion } from "../sync/state.js";
|
|
16
|
+
import { pullAction } from "./pull.js";
|
|
17
|
+
import { authorizeDevice, clearPendingSetupArtifacts, fromB64, persistVaultSecrets, toB64, } from "./bootstrap.js";
|
|
18
|
+
import { runWithProvisioningRollback } from "./provisioning.js";
|
|
19
|
+
export function selectGrantCapableDevices(devices, currentDeviceId) {
|
|
20
|
+
return devices.filter((device) => device.device_id !== currentDeviceId &&
|
|
21
|
+
device.status === "active" &&
|
|
22
|
+
device.grant_capable === true);
|
|
23
|
+
}
|
|
24
|
+
async function receiveApprovedGrant(params) {
|
|
25
|
+
const grantRequest = await requestGrant(params.client, params.deviceId);
|
|
26
|
+
const fallbackDeadline = Date.now() + 20 * 60 * 1000;
|
|
27
|
+
const deadline = Number.isFinite(Date.parse(grantRequest.expires_at))
|
|
28
|
+
? Date.parse(grantRequest.expires_at)
|
|
29
|
+
: fallbackDeadline;
|
|
30
|
+
console.log("Trusted device approval required.");
|
|
31
|
+
console.log("On an existing unlocked device, run `openclaw membox grants approve-pending`.");
|
|
32
|
+
console.log("Waiting for device grant approval...");
|
|
33
|
+
while (Date.now() < deadline) {
|
|
34
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
35
|
+
const grant = await getGrant(params.client, grantRequest.grant_id);
|
|
36
|
+
if (grant.status === "pending") {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (grant.status === "rejected") {
|
|
40
|
+
throw new Error("Trusted-device approval was rejected.");
|
|
41
|
+
}
|
|
42
|
+
if (grant.status === "expired") {
|
|
43
|
+
throw new Error("Trusted-device approval expired before it was approved.");
|
|
44
|
+
}
|
|
45
|
+
if (grant.status !== "approved") {
|
|
46
|
+
throw new Error(`Unexpected grant status: ${grant.status}`);
|
|
47
|
+
}
|
|
48
|
+
if (!grant.encrypted_grant_payload_b64 ||
|
|
49
|
+
!grant.grant_signature_b64 ||
|
|
50
|
+
!grant.source_device_sign_public_key_b64 ||
|
|
51
|
+
!grant.source_device_kex_public_key_b64) {
|
|
52
|
+
throw new Error("Approved grant response is missing encrypted payload or source device public keys.");
|
|
53
|
+
}
|
|
54
|
+
return receiveDeviceGrant({
|
|
55
|
+
targetXPrivateKey: params.deviceKeys.x25519.privateKey,
|
|
56
|
+
targetDeviceId: params.deviceId,
|
|
57
|
+
sourceKexPublicKey: fromB64(grant.source_device_kex_public_key_b64),
|
|
58
|
+
sourceSigningPublicKey: fromB64(grant.source_device_sign_public_key_b64),
|
|
59
|
+
payloadBytes: fromB64(grant.encrypted_grant_payload_b64),
|
|
60
|
+
signature: fromB64(grant.grant_signature_b64),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
throw new Error("Timed out waiting for trusted-device approval.");
|
|
64
|
+
}
|
|
65
|
+
export async function finishAuthorizedSetup(cfg, auth, params) {
|
|
66
|
+
const recoveryStatus = await getRecoveryStatus(auth.client).catch(() => null);
|
|
67
|
+
const syncStatus = await getSyncStatus(auth.client).catch(() => null);
|
|
68
|
+
const devices = await listDevices(auth.client).catch(() => []);
|
|
69
|
+
const otherGrantCapableDevices = selectGrantCapableDevices(devices, auth.tokens.device_id);
|
|
70
|
+
let amk = null;
|
|
71
|
+
let initialMode = "upload";
|
|
72
|
+
try {
|
|
73
|
+
if (otherGrantCapableDevices.length > 0) {
|
|
74
|
+
amk = await receiveApprovedGrant({
|
|
75
|
+
client: auth.client,
|
|
76
|
+
deviceId: auth.tokens.device_id,
|
|
77
|
+
deviceKeys: auth.deviceKeys,
|
|
78
|
+
});
|
|
79
|
+
initialMode = "pull";
|
|
80
|
+
}
|
|
81
|
+
else if (recoveryStatus?.has_recovery_bundle) {
|
|
82
|
+
console.log("Recovery materials already exist for this account, but no active trusted device is available.");
|
|
83
|
+
console.log("Run `openclaw membox restore` on this device instead.");
|
|
84
|
+
throw new Error("Recovery materials already exist for this account, but no active trusted device is available. Use restore on this device instead.");
|
|
85
|
+
}
|
|
86
|
+
else if ((syncStatus?.object_count ?? 0) > 0) {
|
|
87
|
+
console.log("This account already has encrypted sync objects but no active trusted device or recovery bundle is available.");
|
|
88
|
+
console.log("Refusing to generate a new account master key because that would orphan existing server-side data.");
|
|
89
|
+
throw new Error("Encrypted sync objects already exist, but no active trusted device or recovery bundle is available. Refusing to generate a replacement account master key.");
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
console.log("No existing vault detected. Creating a new account master key.");
|
|
93
|
+
amk = generateAMK();
|
|
94
|
+
}
|
|
95
|
+
await persistVaultSecrets({
|
|
96
|
+
deviceKeys: auth.deviceKeys,
|
|
97
|
+
passphrase: params.passphrase,
|
|
98
|
+
amk,
|
|
99
|
+
refreshToken: auth.tokens.refresh_token,
|
|
100
|
+
});
|
|
101
|
+
let recoveryCodeGenerated = false;
|
|
102
|
+
if (initialMode === "upload") {
|
|
103
|
+
if (!params.onRecoveryCode) {
|
|
104
|
+
throw new Error("First-device setup requires a local recovery-code destination so the recovery code can stay on the machine.");
|
|
105
|
+
}
|
|
106
|
+
console.log("\nGenerating recovery materials...");
|
|
107
|
+
const recoveryCode = generateRecoveryCode();
|
|
108
|
+
const bundle = await createRecoveryBundle(recoveryCode, amk);
|
|
109
|
+
const bundleBytes = new TextEncoder().encode(JSON.stringify(bundle));
|
|
110
|
+
await uploadRecoveryMaterial(auth.client, {
|
|
111
|
+
material_type: "recovery_bundle",
|
|
112
|
+
encrypted_payload_b64: toB64(bundleBytes),
|
|
113
|
+
payload_checksum: computeSha256Hex(bundleBytes),
|
|
114
|
+
algorithm: "aes-256-gcm",
|
|
115
|
+
crypto_version: 1,
|
|
116
|
+
});
|
|
117
|
+
await params.onRecoveryCode(recoveryCode);
|
|
118
|
+
recoveryCodeGenerated = true;
|
|
119
|
+
}
|
|
120
|
+
const state = createInitialState({
|
|
121
|
+
serverUrl: cfg.serverUrl,
|
|
122
|
+
deviceId: auth.tokens.device_id,
|
|
123
|
+
deviceName: auth.deviceName,
|
|
124
|
+
userId: auth.account.user_id,
|
|
125
|
+
});
|
|
126
|
+
if (params.enableManagedUnlock) {
|
|
127
|
+
state.managed_unlock_enabled = true;
|
|
128
|
+
}
|
|
129
|
+
await writeState(state);
|
|
130
|
+
if (params.enableManagedUnlock) {
|
|
131
|
+
await enableManagedUnlock(params.passphrase);
|
|
132
|
+
}
|
|
133
|
+
await clearPendingSetupArtifacts();
|
|
134
|
+
vaultSession.unlock(amk);
|
|
135
|
+
if (initialMode === "pull") {
|
|
136
|
+
console.log("Pulling existing encrypted memory...");
|
|
137
|
+
const pullResult = await pullAction({ onConflict: "conflict-copy" });
|
|
138
|
+
const downloadedFiles = pullResult.status === "ok" ? pullResult.downloaded : 0;
|
|
139
|
+
console.log("Setup complete! Existing vault material is now available.");
|
|
140
|
+
return {
|
|
141
|
+
initialMode,
|
|
142
|
+
uploadedFiles: [],
|
|
143
|
+
downloadedFiles,
|
|
144
|
+
recoveryCodeGenerated,
|
|
145
|
+
managedUnlockEnabled: params.enableManagedUnlock === true,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
console.log("Running initial sync...");
|
|
149
|
+
const localFiles = await scanLocalMemoryFiles(process.cwd());
|
|
150
|
+
const uploadedFiles = [];
|
|
151
|
+
for (const [path, file] of localFiles) {
|
|
152
|
+
console.log(` Uploading ${path}...`);
|
|
153
|
+
const result = await uploadFile({
|
|
154
|
+
amk,
|
|
155
|
+
content: file.content,
|
|
156
|
+
logicalPath: path,
|
|
157
|
+
fileKind: file.fileKind,
|
|
158
|
+
userId: state.user_id,
|
|
159
|
+
deviceId: state.device_id,
|
|
160
|
+
client: auth.client,
|
|
161
|
+
});
|
|
162
|
+
await updateFileVersion(path, {
|
|
163
|
+
object_id: result.objectId,
|
|
164
|
+
version: result.version,
|
|
165
|
+
content_sha256: file.sha256,
|
|
166
|
+
});
|
|
167
|
+
uploadedFiles.push(path);
|
|
168
|
+
}
|
|
169
|
+
console.log("Setup complete! Vault is ready.");
|
|
170
|
+
return {
|
|
171
|
+
initialMode,
|
|
172
|
+
uploadedFiles,
|
|
173
|
+
downloadedFiles: 0,
|
|
174
|
+
recoveryCodeGenerated,
|
|
175
|
+
managedUnlockEnabled: params.enableManagedUnlock === true,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
amk?.fill(0);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
export async function setupAction(cfg) {
|
|
183
|
+
const existing = await readState();
|
|
184
|
+
if (existing?.setup_complete) {
|
|
185
|
+
console.log("Vault already set up. Use `openclaw membox status` to check.");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const auth = await authorizeDevice(cfg);
|
|
189
|
+
await runWithProvisioningRollback(auth, async (markProvisioned) => {
|
|
190
|
+
console.log("\nSet your vault passphrase (this encrypts your memory locally):");
|
|
191
|
+
console.log("Warning: This passphrase is NEVER sent to the server. If lost, use recovery materials.");
|
|
192
|
+
const passphrase = await readPassphraseWithConfirm("Vault passphrase: ");
|
|
193
|
+
const result = await finishAuthorizedSetup(cfg, auth, {
|
|
194
|
+
passphrase,
|
|
195
|
+
onRecoveryCode: async (recoveryCode) => {
|
|
196
|
+
console.log("\n========================================");
|
|
197
|
+
console.log(" SAVE YOUR RECOVERY CODE NOW!");
|
|
198
|
+
console.log("");
|
|
199
|
+
console.log(` ${recoveryCode}`);
|
|
200
|
+
console.log("");
|
|
201
|
+
console.log(" Store it in a password manager or print.");
|
|
202
|
+
console.log(" Without it + passphrase, data is lost.");
|
|
203
|
+
console.log("========================================\n");
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
markProvisioned();
|
|
207
|
+
return result;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function statusAction(): Promise<void>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readState } from "../store/local-state.js";
|
|
2
|
+
import { vaultSession } from "../store/session.js";
|
|
3
|
+
import { getManagedUnlockStatus } from "../store/managed-unlock.js";
|
|
4
|
+
export async function statusAction() {
|
|
5
|
+
const state = await readState();
|
|
6
|
+
if (!state?.setup_complete) {
|
|
7
|
+
console.log("Membox Vault: not set up");
|
|
8
|
+
console.log(" Run `openclaw membox setup` to get started.");
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const fileCount = Object.keys(state.file_versions).length;
|
|
12
|
+
const managedUnlock = await getManagedUnlockStatus();
|
|
13
|
+
console.log("Membox Vault Status");
|
|
14
|
+
console.log("-------------------");
|
|
15
|
+
console.log(` Server: ${state.server_url}`);
|
|
16
|
+
console.log(` Device: ${state.device_name} (${state.device_id.slice(0, 8)}...)`);
|
|
17
|
+
console.log(` User: ${state.user_id.slice(0, 8)}...`);
|
|
18
|
+
console.log(` Unlocked: ${vaultSession.isUnlocked() ? "yes" : "no"}`);
|
|
19
|
+
console.log(` Managed: ${managedUnlock.enabled
|
|
20
|
+
? managedUnlock.secret_present
|
|
21
|
+
? "enabled"
|
|
22
|
+
: "enabled (secret missing)"
|
|
23
|
+
: "disabled"}`);
|
|
24
|
+
console.log(` Cursor: ${state.sync_cursor}`);
|
|
25
|
+
console.log(` Files: ${fileCount}`);
|
|
26
|
+
if (fileCount > 0) {
|
|
27
|
+
for (const [path, v] of Object.entries(state.file_versions)) {
|
|
28
|
+
console.log(` ${path} (v${v.version})`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { writeFile, mkdir, rename } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve, relative, isAbsolute } from "node:path";
|
|
3
|
+
import { vaultSession } from "../store/session.js";
|
|
4
|
+
import { getState } from "../sync/state.js";
|
|
5
|
+
import { scanLocalMemoryFiles } from "../sync/scanner.js";
|
|
6
|
+
import { computeSyncDiff } from "../sync/diff.js";
|
|
7
|
+
import { uploadFile } from "../sync/uploader.js";
|
|
8
|
+
import { downloadAndDecrypt } from "../sync/downloader.js";
|
|
9
|
+
import { getSyncChanges } from "../api/sync.js";
|
|
10
|
+
import { updateCursor, updateFileVersion } from "../sync/state.js";
|
|
11
|
+
import { createAuthenticatedClient } from "./helpers.js";
|
|
12
|
+
import { computeSha256Hex } from "../crypto/manifest.js";
|
|
13
|
+
import { readState, writeState } from "../store/local-state.js";
|
|
14
|
+
import { resolveConflict } from "../sync/conflict.js";
|
|
15
|
+
import { ensureVaultUnlocked } from "./unlock.js";
|
|
16
|
+
function isSafePath(base, logicalPath) {
|
|
17
|
+
const resolved = resolve(base, logicalPath);
|
|
18
|
+
const rel = relative(base, resolved);
|
|
19
|
+
return !rel.startsWith("..") && !isAbsolute(rel);
|
|
20
|
+
}
|
|
21
|
+
export async function syncAction(options) {
|
|
22
|
+
if (!(await ensureVaultUnlocked({ announceSuccess: true }))) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const amk = vaultSession.getAMK();
|
|
26
|
+
const state = await getState();
|
|
27
|
+
const client = await createAuthenticatedClient(state);
|
|
28
|
+
// Scan local files
|
|
29
|
+
const localFiles = await scanLocalMemoryFiles(process.cwd());
|
|
30
|
+
console.log(`Scanned ${localFiles.size} local memory files.`);
|
|
31
|
+
// Check remote changes
|
|
32
|
+
const remote = await getSyncChanges(client, state.sync_cursor);
|
|
33
|
+
const diff = computeSyncDiff(localFiles, remote.changes, state);
|
|
34
|
+
const strategy = options?.onConflict ?? "conflict-copy";
|
|
35
|
+
// Handle conflicts with chosen strategy
|
|
36
|
+
if (diff.conflicts.length > 0) {
|
|
37
|
+
console.log(`${diff.conflicts.length} conflict(s) detected (strategy: ${strategy}):`);
|
|
38
|
+
for (const c of diff.conflicts) {
|
|
39
|
+
const localFile = localFiles.get(c.logicalPath);
|
|
40
|
+
const existing = state.file_versions[c.logicalPath];
|
|
41
|
+
const result = await resolveConflict({
|
|
42
|
+
logicalPath: c.logicalPath,
|
|
43
|
+
objectId: c.objectId,
|
|
44
|
+
localFile,
|
|
45
|
+
existingVersion: existing?.version,
|
|
46
|
+
}, strategy, {
|
|
47
|
+
amk,
|
|
48
|
+
client,
|
|
49
|
+
userId: state.user_id,
|
|
50
|
+
deviceId: state.device_id,
|
|
51
|
+
workspaceDir: process.cwd(),
|
|
52
|
+
});
|
|
53
|
+
if (result.fileVersionUpdate) {
|
|
54
|
+
await updateFileVersion(result.fileVersionUpdate.path, result.fileVersionUpdate.entry);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Upload changed files
|
|
59
|
+
for (const item of diff.toUpload) {
|
|
60
|
+
console.log(` Uploading ${item.logicalPath}...`);
|
|
61
|
+
const result = await uploadFile({
|
|
62
|
+
amk,
|
|
63
|
+
content: item.file.content,
|
|
64
|
+
logicalPath: item.logicalPath,
|
|
65
|
+
fileKind: item.file.fileKind,
|
|
66
|
+
userId: state.user_id,
|
|
67
|
+
deviceId: state.device_id,
|
|
68
|
+
objectId: item.existingObjectId,
|
|
69
|
+
objectVersion: item.existingVersion,
|
|
70
|
+
client,
|
|
71
|
+
});
|
|
72
|
+
await updateFileVersion(item.logicalPath, {
|
|
73
|
+
object_id: result.objectId,
|
|
74
|
+
version: result.version,
|
|
75
|
+
content_sha256: item.file.sha256,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
// Download remote changes
|
|
79
|
+
let downloaded = 0;
|
|
80
|
+
const base = process.cwd();
|
|
81
|
+
for (const item of diff.toDownload) {
|
|
82
|
+
if (item.changeType === "delete") {
|
|
83
|
+
console.log(` Tombstone for ${item.objectId} (skip local delete in V0.1)`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
console.log(` Downloading ${item.objectId}...`);
|
|
87
|
+
const result = await downloadAndDecrypt({
|
|
88
|
+
amk,
|
|
89
|
+
objectId: item.objectId,
|
|
90
|
+
client,
|
|
91
|
+
version: item.version,
|
|
92
|
+
});
|
|
93
|
+
if (!isSafePath(base, result.logicalPath)) {
|
|
94
|
+
console.log(` Skipping unsafe path: ${result.logicalPath}`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const outPath = resolve(base, result.logicalPath);
|
|
98
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
99
|
+
const tmp = outPath + ".tmp." + Date.now();
|
|
100
|
+
await writeFile(tmp, result.content);
|
|
101
|
+
await rename(tmp, outPath);
|
|
102
|
+
console.log(` -> ${result.logicalPath}`);
|
|
103
|
+
await updateFileVersion(result.logicalPath, {
|
|
104
|
+
object_id: item.objectId,
|
|
105
|
+
version: result.version,
|
|
106
|
+
content_sha256: computeSha256Hex(result.content),
|
|
107
|
+
});
|
|
108
|
+
downloaded++;
|
|
109
|
+
}
|
|
110
|
+
await updateCursor(remote.cursor);
|
|
111
|
+
// Update last_sync_at timestamp
|
|
112
|
+
const currentState = await readState();
|
|
113
|
+
if (currentState) {
|
|
114
|
+
currentState.last_sync_at = new Date().toISOString();
|
|
115
|
+
await writeState(currentState);
|
|
116
|
+
}
|
|
117
|
+
const uploaded = diff.toUpload.length;
|
|
118
|
+
if (uploaded === 0 && downloaded === 0 && diff.conflicts.length === 0) {
|
|
119
|
+
console.log("Already up to date.");
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
console.log(`Sync complete. ${uploaded} uploaded, ${downloaded} downloaded, ${diff.conflicts.length} conflict(s) resolved.`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare function unlockWithPassphrase(passphrase: string, options?: {
|
|
2
|
+
announceSuccess?: boolean;
|
|
3
|
+
announceFailure?: boolean;
|
|
4
|
+
announceDeriving?: boolean;
|
|
5
|
+
}): Promise<boolean>;
|
|
6
|
+
export declare function tryManagedUnlock(options?: {
|
|
7
|
+
announceDeriving?: boolean;
|
|
8
|
+
announceSuccess?: boolean;
|
|
9
|
+
announceFailure?: boolean;
|
|
10
|
+
}): Promise<boolean>;
|
|
11
|
+
export declare function ensureVaultUnlocked(options?: {
|
|
12
|
+
announceAlreadyUnlocked?: boolean;
|
|
13
|
+
announceSuccess?: boolean;
|
|
14
|
+
allowPrompt?: boolean;
|
|
15
|
+
allowManagedUnlock?: boolean;
|
|
16
|
+
}): Promise<boolean>;
|
|
17
|
+
export declare function unlockAction(): Promise<void>;
|
|
18
|
+
export declare function enableManagedUnlockAction(passphrase: string): Promise<void>;
|
|
19
|
+
export declare function disableManagedUnlockAction(): Promise<void>;
|