@zktx.io/sui-mcp 0.5.0 → 0.6.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 +6 -6
- package/dist/adapters/deepbook/deepbookTransactionMaterialProducer.js +3 -3
- package/dist/core/activity/localDataService.js +71 -52
- package/dist/core/activity/localDataValidation.js +1 -3
- package/dist/core/activity/sqliteActivityStore.js +24 -16
- package/dist/core/activity/sqliteActivityStoreSchema.js +83 -12
- package/dist/core/preferences/sqlitePreferencesRepository.js +5 -3
- package/dist/core/session/cardSession.js +1 -1
- package/dist/core/session/cardSessionStore.js +53 -3
- package/dist/core/session/sessionStore.js +0 -17
- package/dist/core/session/settingsFileIdentity.js +11 -0
- package/dist/core/session/settingsFileProtocol.js +25 -0
- package/dist/core/session/settingsFileStore.js +346 -0
- package/dist/core/session/settingsInputError.js +34 -0
- package/dist/core/session/settingsOperation.js +224 -0
- package/dist/core/session/settingsPreparationSql.js +15 -0
- package/dist/core/session/settingsWorkflow.js +1219 -0
- package/dist/core/session/sqliteSessionStore.js +0 -40
- package/dist/core/session/sqliteSettingsWorkflowStore.js +463 -0
- package/dist/core/session/sqliteWalletWorkflowStore.js +164 -41
- package/dist/core/session/status.js +2 -2
- package/dist/core/session/walletConnection.js +27 -8
- package/dist/core/session/walletWorkflow.js +73 -15
- package/dist/core/session/workflowState.js +16 -8
- package/dist/core/session/workflowView.js +18 -2
- package/dist/mcp/responseGuidance.js +10 -5
- package/dist/mcp/serverInfo.js +3 -4
- package/dist/mcp/toolNames.js +2 -2
- package/dist/mcp/tools/account/index.js +1 -19
- package/dist/mcp/tools/read/readToolHelpers.js +5 -4
- package/dist/mcp/tools/session/executionResultTools.js +2 -2
- package/dist/mcp/tools/session/shared.js +1 -0
- package/dist/mcp/tools/session/statusTools.js +4 -3
- package/dist/mcp/tools/settings/index.js +8 -28
- package/dist/mcp-app/account.html +14 -14
- package/dist/mcp-app/account.notices.txt +1 -1
- package/dist/mcp-app/chart.html +9 -9
- package/dist/mcp-app/chart.notices.txt +1 -1
- package/dist/mcp-app/connect.html +14 -14
- package/dist/mcp-app/connect.notices.txt +1 -1
- package/dist/mcp-app/receipt.html +1298 -1463
- package/dist/mcp-app/receipt.notices.txt +226 -1204
- package/dist/mcp-app/review.html +1298 -1463
- package/dist/mcp-app/review.notices.txt +226 -1204
- package/dist/mcp-app/settings.html +3133 -0
- package/dist/mcp-app/settings.notices.txt +973 -0
- package/dist/mcp-ui/contracts.js +8 -2
- package/dist/mcp-ui/readCards.js +1 -1
- package/dist/mcp-ui/tools.js +33 -2
- package/dist/review-server/server.js +5 -51
- package/dist/runtime/application.js +41 -14
- package/dist/runtime/localSettingsService.js +26 -2
- package/dist/runtime/shared/authenticatedFetch.js +6 -2
- package/dist/runtime/shared/control.js +5 -6
- package/dist/runtime/shared/http.js +14 -0
- package/dist/runtime/shared/mcpHttp.js +9 -4
- package/dist/runtime/shared/server.js +1 -1
- package/dist/runtime/smokeMainnetRead.js +0 -1
- package/dist/runtime/walletConnectStorage.js +2 -4
- package/dist/runtime/walletSdkChildRuntime.js +2 -1
- package/dist/runtime/walletSdkIpc.js +2 -1
- package/dist/runtime/walletSdkProcess.js +3 -2
- package/docs/AGENT_BEHAVIOR.md +44 -10
- package/docs/AGENT_DEVELOPMENT_POLICY.md +16 -12
- package/docs/FRONTEND_POLICY.md +105 -28
- package/docs/LOCAL_DB_ARCHITECTURE.md +143 -20
- package/docs/MCP_SETUP.md +99 -39
- package/docs/MCP_TOOLS.md +273 -24
- package/docs/SDK_API.md +51 -10
- package/docs/UTILITY_INDEX.md +9 -6
- package/docs/WALLET_CONNECTION.md +30 -9
- package/package.json +13 -7
- package/dist/core/activity/schemaVersion.js +0 -2
- package/dist/core/session/keyedRecordStore.js +0 -18
- package/dist/core/session/settingsSession.js +0 -1
- package/dist/core/session/settingsSessions.js +0 -45
- package/dist/review-app/brand-dark.svg +0 -1
- package/dist/review-app/brand-light.svg +0 -1
- package/dist/review-app/favicon.svg +0 -1
- package/dist/review-app/settings.css +0 -1
- package/dist/review-app/settings.js +0 -1
- package/dist/review-app/ui.css +0 -809
- package/dist/review-server/assets.js +0 -48
- package/dist/review-server/html.js +0 -45
- package/dist/review-server/http.js +0 -47
- package/dist/review-server/middleware/reviewToken.js +0 -7
- package/dist/review-server/settingsApi.js +0 -158
|
@@ -8,6 +8,9 @@ import { assertNoForbiddenMcpFields } from "../action/forbiddenFields.js";
|
|
|
8
8
|
import { cardSnapshotSchema } from "./cardSession.js";
|
|
9
9
|
import { WorkflowConflict } from "./sqliteWalletWorkflowStore.js";
|
|
10
10
|
import { parseWorkflowAction } from "./workflowView.js";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { settingsActionSchema, isSettingsDownloadFileAction, SettingsConflict } from "./settingsOperation.js";
|
|
13
|
+
import { settingsInputError } from "./settingsInputError.js";
|
|
11
14
|
export class CardError extends Error {
|
|
12
15
|
}
|
|
13
16
|
export class CardStore {
|
|
@@ -27,6 +30,8 @@ export class CardStore {
|
|
|
27
30
|
async create(kind, input, execute = false) {
|
|
28
31
|
this.assertCurrent();
|
|
29
32
|
const business = kind === "connect" || kind === "review";
|
|
33
|
+
if (kind === "settings" && (!this.options.settings || execute || Object.keys(input).length))
|
|
34
|
+
throw new CardError("Settings cards are unavailable for this input.");
|
|
30
35
|
if (business && (!this.options.workflow || execute))
|
|
31
36
|
throw new CardError("This type of card is unavailable.");
|
|
32
37
|
const parsed = execute ? parseCardInput(kind, input) : undefined;
|
|
@@ -49,13 +54,17 @@ export class CardStore {
|
|
|
49
54
|
assertNoForbiddenMcpFields({ input, preparation });
|
|
50
55
|
const { base, token } = createLocalSessionBase(this.now(), DEFAULT_SESSION_TTL_MS);
|
|
51
56
|
const record = { ownerId: this.options.ownerId, tokenHash: base.tokenHash,
|
|
52
|
-
scope: kind === "connect" ? "connect" : kind === "review" ? input.mode === "manage" ? "review_manage" : "review" : "read",
|
|
57
|
+
scope: kind === "settings" ? "settings" : kind === "connect" ? "connect" : kind === "review" ? input.mode === "manage" ? "review_manage" : "review" : "read",
|
|
58
|
+
...(kind === "settings" ? { operationId: randomUUID() } : {}),
|
|
53
59
|
...(kind === "review" && input.mode === "manage" && typeof input.attemptId === "string" ? { operationId: input.attemptId } : {}),
|
|
54
60
|
state: { cardId: base.id, kind, state: preparation.status === "failed" ? "closed" : "ready", revision: 0,
|
|
55
61
|
createdAt: base.createdAt, expiresAt: base.expiresAt, input,
|
|
56
62
|
...(preparation.status === "failed" ? { reason: "failed", error: preparation.error } :
|
|
57
63
|
preparation.data === undefined ? {} : { data: preparation.data }) } };
|
|
58
|
-
|
|
64
|
+
if (kind === "settings")
|
|
65
|
+
this.options.settings.create(record);
|
|
66
|
+
else
|
|
67
|
+
this.options.records.create(record);
|
|
59
68
|
if (preparation.status === "failed")
|
|
60
69
|
this.reportFailure(record, "input_preparation");
|
|
61
70
|
const result = parsed === undefined ? await this.describe(record, false) : await this.submit({
|
|
@@ -64,8 +73,24 @@ export class CardStore {
|
|
|
64
73
|
return { ...result, permission: token };
|
|
65
74
|
}
|
|
66
75
|
async read(input) { return this.describe(this.require(input), true); }
|
|
76
|
+
async readSettingsFile(input, limit) {
|
|
77
|
+
const record = this.require(input);
|
|
78
|
+
if (record.state.kind !== "settings" || !this.options.settings)
|
|
79
|
+
throw new CardError("This card does not provide a backup file.");
|
|
80
|
+
if (record.operationId !== input.operationId)
|
|
81
|
+
throw new CardError("This backup does not match the Settings operation.");
|
|
82
|
+
return this.options.settings.readFile(record, input.fileId, input.offset, limit, input.attemptId);
|
|
83
|
+
}
|
|
67
84
|
async readSaved(cardId) { return (await this.describe(this.current(cardId), false)).snapshot; }
|
|
68
85
|
async describe(record, uiObservation) {
|
|
86
|
+
if (record.state.kind === "settings") {
|
|
87
|
+
if (!this.options.settings)
|
|
88
|
+
throw new CardError("Settings cards are unavailable.");
|
|
89
|
+
const described = await this.options.settings.describe(record);
|
|
90
|
+
return { snapshot: cardSnapshotSchema.parse({ ...described.record.state, data: described.data,
|
|
91
|
+
inputRemainingMs: described.record.state.state === "ready" ? Math.max(0, Date.parse(described.record.state.expiresAt) - this.now().getTime()) : 0,
|
|
92
|
+
pollAfterMs: EXECUTION_POLLING_INTERVAL_SECONDS * 1000 }) };
|
|
93
|
+
}
|
|
69
94
|
if (record.state.kind !== "connect" && record.state.kind !== "review")
|
|
70
95
|
return this.response(record);
|
|
71
96
|
if (!this.options.workflow)
|
|
@@ -83,6 +108,31 @@ export class CardStore {
|
|
|
83
108
|
}
|
|
84
109
|
async act(input) {
|
|
85
110
|
const record = this.require(input);
|
|
111
|
+
if (record.state.kind === "settings") {
|
|
112
|
+
if (!this.options.settings)
|
|
113
|
+
throw new CardError("Settings cards are unavailable.");
|
|
114
|
+
const parsed = settingsActionSchema.safeParse(input.input);
|
|
115
|
+
if (!parsed.success)
|
|
116
|
+
return { ...await this.describe(record, false), error: { code: "invalid_card_input", message: "This Settings action is not valid." } };
|
|
117
|
+
if ((parsed.data.action === "settings_confirm" || parsed.data.action === "settings_download_backup") && record.acceptedInput !== undefined && isDeepStrictEqual(record.acceptedInput, parsed.data))
|
|
118
|
+
return this.describe(record, false);
|
|
119
|
+
if (input.revision !== record.state.revision && !isSettingsDownloadFileAction(parsed.data))
|
|
120
|
+
return { ...await this.describe(record, false), error: { code: "card_conflict", message: "This Settings selection changed. Review the current selection before continuing." } };
|
|
121
|
+
const kind = parsed.data.action === "settings_select" ? parsed.data.draft.kind : this.options.settings.operationKind(record);
|
|
122
|
+
try {
|
|
123
|
+
await this.options.settings.act(record, parsed.data);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
const invalid = settingsInputError(error, kind);
|
|
127
|
+
if (!(error instanceof SettingsConflict) && invalid === undefined)
|
|
128
|
+
throw error;
|
|
129
|
+
return { ...await this.describe(this.current(input.cardId), false), error: {
|
|
130
|
+
code: invalid === undefined ? "card_conflict" : "invalid_card_input",
|
|
131
|
+
message: invalid?.message ?? error.message
|
|
132
|
+
} };
|
|
133
|
+
}
|
|
134
|
+
return this.describe(this.current(input.cardId), false);
|
|
135
|
+
}
|
|
86
136
|
if ((record.state.kind !== "connect" && record.state.kind !== "review") || !this.options.workflow) {
|
|
87
137
|
return this.conflict(record, "This card does not accept wallet workflow actions.");
|
|
88
138
|
}
|
|
@@ -112,7 +162,7 @@ export class CardStore {
|
|
|
112
162
|
}
|
|
113
163
|
async submit(input) {
|
|
114
164
|
const record = this.require(input);
|
|
115
|
-
if (record.state.kind === "connect" || record.state.kind === "review")
|
|
165
|
+
if (record.state.kind === "connect" || record.state.kind === "review" || record.state.kind === "settings")
|
|
116
166
|
throw new CardError("This card does not accept data selections.");
|
|
117
167
|
let parsed;
|
|
118
168
|
try {
|
|
@@ -16,7 +16,6 @@ import { isFinalSessionStatus } from "./status.js";
|
|
|
16
16
|
import { parseSuiAddress } from "../suiAddress.js";
|
|
17
17
|
import { cloneLocalSession, createLocalSessionBase, DEFAULT_SESSION_TTL_MS, isLocalSessionExpired, tokenMatchesHash } from "./localSession.js";
|
|
18
18
|
import { SessionStoreError } from "./sessionErrors.js";
|
|
19
|
-
import { SettingsSessionManager } from "./settingsSessions.js";
|
|
20
19
|
const PRIVATE_DERIVED_REVIEW_FIELD_BINDINGS = [
|
|
21
20
|
{
|
|
22
21
|
field: "humanReadableReview",
|
|
@@ -51,7 +50,6 @@ export class LocalSessionStore {
|
|
|
51
50
|
sessions;
|
|
52
51
|
privateReviewArtifacts;
|
|
53
52
|
ownerId;
|
|
54
|
-
settings;
|
|
55
53
|
ttlMs;
|
|
56
54
|
clock;
|
|
57
55
|
eventLog;
|
|
@@ -65,11 +63,6 @@ export class LocalSessionStore {
|
|
|
65
63
|
this.privateReviewArtifacts = options.artifacts;
|
|
66
64
|
this.ttlMs = options.ttlMs ?? DEFAULT_SESSION_TTL_MS;
|
|
67
65
|
this.ownerId = options.ownerId ?? randomUUID();
|
|
68
|
-
this.settings = new SettingsSessionManager({
|
|
69
|
-
ttlMs: this.ttlMs,
|
|
70
|
-
appendEventLog: (record) => this.appendEventLog(record),
|
|
71
|
-
...(options.settingsStore ? { recordStore: options.settingsStore } : {})
|
|
72
|
-
});
|
|
73
66
|
this.eventLog = options.eventLog ?? new NullEventLogSink();
|
|
74
67
|
this.activityStore = options.activityStore;
|
|
75
68
|
this.transactionMaterialStore = options.transactionMaterialStore;
|
|
@@ -327,15 +320,6 @@ export class LocalSessionStore {
|
|
|
327
320
|
}
|
|
328
321
|
return session;
|
|
329
322
|
}
|
|
330
|
-
async createSettingsSession(now = new Date()) {
|
|
331
|
-
return this.settings.create(now);
|
|
332
|
-
}
|
|
333
|
-
async getSettingsSession(id, now = new Date()) {
|
|
334
|
-
return this.settings.get(id, now);
|
|
335
|
-
}
|
|
336
|
-
async validateSettingsToken(id, token, now = new Date()) {
|
|
337
|
-
return this.settings.validateToken(id, token, now);
|
|
338
|
-
}
|
|
339
323
|
async prepareReviewedTransaction(id, planId, account, now = this.clock()) {
|
|
340
324
|
const candidate = await this.inspectReview(id, now);
|
|
341
325
|
if (!candidate)
|
|
@@ -360,7 +344,6 @@ export class LocalSessionStore {
|
|
|
360
344
|
this.deleteReviewSessionTransactionMaterials(id);
|
|
361
345
|
}
|
|
362
346
|
this.sessions.clear();
|
|
363
|
-
this.settings.clear();
|
|
364
347
|
await this.appendEventLog({
|
|
365
348
|
type: "local_sessions.invalidated",
|
|
366
349
|
sessionId: "all",
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Both the filesystem and replacement/cleanup journal use this exact private
|
|
2
|
+
// namespace. A display name or an export role alone is never a download path.
|
|
3
|
+
export function settingsDownloadPath(fileId) {
|
|
4
|
+
return `.settings-staging/${fileId}.download`;
|
|
5
|
+
}
|
|
6
|
+
export function settingsBackupPath(operationId) { return `backups/${operationId}.json`; }
|
|
7
|
+
export function isSettingsDownloadFile(file) {
|
|
8
|
+
return file.role === "export" && file.relativePath === settingsDownloadPath(file.fileId);
|
|
9
|
+
}
|
|
10
|
+
export const SETTINGS_DOWNLOAD_FILE_SQL = "(role='export' AND relative_path=('.settings-staging/' || file_id || '.download'))";
|
|
11
|
+
export const SETTINGS_FILE_CLEANUP_SQL = `(invalidated=1 OR (role='export' AND NOT ${SETTINGS_DOWNLOAD_FILE_SQL}))`;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The existing internal card/HTTP limit applies to the complete JSON-RPC message.
|
|
2
|
+
import { SETTINGS_IMPORT_MAX_SIZE } from "./settingsOperation.js";
|
|
3
|
+
export const MAX_JSON_BODY_BYTES = 64 * 1024;
|
|
4
|
+
export function settingsJsonSize(value) { return new TextEncoder().encode(JSON.stringify(value)).length; }
|
|
5
|
+
export function settingsRawChunkBudget(envelopeWithEmptyBase64) {
|
|
6
|
+
const room = MAX_JSON_BODY_BYTES - settingsJsonSize(envelopeWithEmptyBase64);
|
|
7
|
+
return room > 0 ? 3 * Math.floor(room / 4) : 0;
|
|
8
|
+
}
|
|
9
|
+
// The actual request may carry additional Host metadata. Rejection happens
|
|
10
|
+
// before dispatch; its independently measured room lets the same offset retry.
|
|
11
|
+
export function settingsUploadMessageBudget(message) {
|
|
12
|
+
if (!message || typeof message !== "object")
|
|
13
|
+
return undefined;
|
|
14
|
+
const envelope = structuredClone(message);
|
|
15
|
+
if (envelope.method !== "tools/call" || envelope.params?.name !== "ui.act_card" ||
|
|
16
|
+
envelope.params.arguments?.input?.action !== "settings_upload_chunk")
|
|
17
|
+
return undefined;
|
|
18
|
+
// Safe-integer decimal maxima cover ids/revisions; upload offsets use the
|
|
19
|
+
// accepted raw-file limit.
|
|
20
|
+
envelope.id = Number.MAX_SAFE_INTEGER;
|
|
21
|
+
envelope.params.arguments.revision = Number.MAX_SAFE_INTEGER;
|
|
22
|
+
envelope.params.arguments.input.offset = SETTINGS_IMPORT_MAX_SIZE;
|
|
23
|
+
envelope.params.arguments.input.chunkBase64 = "";
|
|
24
|
+
return settingsRawChunkBudget(envelope);
|
|
25
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, open, link, unlink } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { isSettingsDownloadFile, settingsBackupPath } from "./settingsFileIdentity.js";
|
|
6
|
+
import { SettingsConflict } from "./settingsOperation.js";
|
|
7
|
+
export class SettingsDownloadPathExists extends SettingsConflict {
|
|
8
|
+
}
|
|
9
|
+
function fileCode(error, code) {
|
|
10
|
+
return !!error && typeof error === "object" && "code" in error && error.code === code;
|
|
11
|
+
}
|
|
12
|
+
export function settingsFileDigest(data) { return createHash("sha256").update(data).digest("hex"); }
|
|
13
|
+
export function decodeSettingsChunk(value) {
|
|
14
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
|
|
15
|
+
throw new SettingsConflict("Part of the file was not encoded correctly.");
|
|
16
|
+
}
|
|
17
|
+
const data = Buffer.from(value, "base64");
|
|
18
|
+
if (data.toString("base64") !== value)
|
|
19
|
+
throw new SettingsConflict("Part of the file was not encoded correctly.");
|
|
20
|
+
return data;
|
|
21
|
+
}
|
|
22
|
+
/** Private files are named from backend-generated identities only. SQLite owns
|
|
23
|
+
* reservations and progress; this class verifies the corresponding physical fact. */
|
|
24
|
+
export class SettingsFileStore {
|
|
25
|
+
dataDirectory;
|
|
26
|
+
constructor(dataDirectory) {
|
|
27
|
+
this.dataDirectory = dataDirectory;
|
|
28
|
+
}
|
|
29
|
+
directory(role) {
|
|
30
|
+
return join(this.dataDirectory, role === "staging" ? ".settings-staging" : "backups");
|
|
31
|
+
}
|
|
32
|
+
assertId(id) {
|
|
33
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
|
|
34
|
+
throw new SettingsConflict("The selected file could not be identified.");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async prepareDirectory(role) {
|
|
38
|
+
const directory = this.directory(role);
|
|
39
|
+
await mkdir(directory, { mode: 0o700, recursive: true });
|
|
40
|
+
const info = await lstat(directory);
|
|
41
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
42
|
+
throw new SettingsConflict("The local file directory is unavailable.");
|
|
43
|
+
return directory;
|
|
44
|
+
}
|
|
45
|
+
stagingPath(file) {
|
|
46
|
+
this.assertId(file.fileId);
|
|
47
|
+
return join(this.directory("staging"), `${file.fileId}.${file.role === "upload" ? "upload" : isSettingsDownloadFile(file) ? "download" : "stage"}`);
|
|
48
|
+
}
|
|
49
|
+
finalPath(file) {
|
|
50
|
+
this.assertId(file.operationId);
|
|
51
|
+
if (file.role !== "export" || file.relativePath !== `backups/${file.operationId}.json`) {
|
|
52
|
+
throw new SettingsConflict("The backup location does not match this request.");
|
|
53
|
+
}
|
|
54
|
+
return join(this.directory("backups"), `${file.operationId}.json`);
|
|
55
|
+
}
|
|
56
|
+
exportPath(file) { return this.finalPath(file); }
|
|
57
|
+
localBackupPath(file) {
|
|
58
|
+
this.assertId(file.operationId);
|
|
59
|
+
if (!isSettingsDownloadFile(file))
|
|
60
|
+
throw new SettingsConflict("This request does not own a download snapshot.");
|
|
61
|
+
return join(this.dataDirectory, settingsBackupPath(file.operationId));
|
|
62
|
+
}
|
|
63
|
+
async verifiedIdentity(handle, path, file) {
|
|
64
|
+
const before = await handle.stat({ bigint: true });
|
|
65
|
+
if (!before.isFile() || before.size !== BigInt(file.totalSize))
|
|
66
|
+
throw new SettingsConflict("The backup file size changed.");
|
|
67
|
+
const hash = createHash("sha256");
|
|
68
|
+
for await (const chunk of handle.createReadStream({ start: 0, autoClose: false }))
|
|
69
|
+
hash.update(chunk);
|
|
70
|
+
const after = await handle.stat({ bigint: true }), atPath = await lstat(path, { bigint: true });
|
|
71
|
+
if (!atPath.isFile() || atPath.isSymbolicLink() || before.dev !== after.dev || before.ino !== after.ino ||
|
|
72
|
+
after.dev !== atPath.dev || after.ino !== atPath.ino || after.size !== before.size || atPath.size !== before.size ||
|
|
73
|
+
hash.digest("hex") !== file.sha256)
|
|
74
|
+
throw new SettingsConflict("The backup file changed while it was checked.");
|
|
75
|
+
return { device: before.dev.toString(), inode: before.ino.toString() };
|
|
76
|
+
}
|
|
77
|
+
sameFile(left, right) {
|
|
78
|
+
return left.device === right.device && left.inode === right.inode;
|
|
79
|
+
}
|
|
80
|
+
async downloadSourceIdentity(file) {
|
|
81
|
+
const directory = await lstat(this.directory("staging"));
|
|
82
|
+
if (!directory.isDirectory() || directory.isSymbolicLink())
|
|
83
|
+
throw new SettingsConflict("The backup staging directory changed.");
|
|
84
|
+
const path = this.stagingPath(file), handle = await this.handle(path, constants.O_RDONLY);
|
|
85
|
+
try {
|
|
86
|
+
return await this.verifiedIdentity(handle, path, file);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
await handle.close();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async publishDownload(file, rememberSource) {
|
|
93
|
+
if (!isSettingsDownloadFile(file) || !file.sealed || file.invalidated)
|
|
94
|
+
throw new SettingsConflict("The snapshot is not ready to save.");
|
|
95
|
+
await this.prepareDirectory("backups");
|
|
96
|
+
const source = this.stagingPath(file), destination = this.localBackupPath(file);
|
|
97
|
+
// Persist the verified source identity before the only effect. Recovery
|
|
98
|
+
// verifies this same inode; it never creates a missing destination.
|
|
99
|
+
const identity = await this.downloadSourceIdentity(file);
|
|
100
|
+
rememberSource(identity);
|
|
101
|
+
try {
|
|
102
|
+
await link(source, destination);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (fileCode(error, "EEXIST"))
|
|
106
|
+
return { status: "failed", reason: "target_conflict" };
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
return this.inspectDownloadPublication(file, identity);
|
|
110
|
+
}
|
|
111
|
+
async inspectDownloadPublication(file, sourceIdentity) {
|
|
112
|
+
if (!isSettingsDownloadFile(file))
|
|
113
|
+
throw new SettingsConflict("The selected file is not a download snapshot.");
|
|
114
|
+
const path = this.localBackupPath(file);
|
|
115
|
+
let target;
|
|
116
|
+
try {
|
|
117
|
+
const directory = await lstat(this.directory("backups"));
|
|
118
|
+
if (!directory.isDirectory() || directory.isSymbolicLink())
|
|
119
|
+
return { status: "failed", reason: "target_conflict" };
|
|
120
|
+
target = await this.handle(path, constants.O_RDONLY);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (fileCode(error, "ENOENT")) {
|
|
124
|
+
try {
|
|
125
|
+
await this.downloadSourceIdentity(file);
|
|
126
|
+
return { status: "failed", reason: "not_published" };
|
|
127
|
+
}
|
|
128
|
+
catch (sourceError) {
|
|
129
|
+
if (sourceError instanceof SettingsConflict || fileCode(sourceError, "ENOENT") || fileCode(sourceError, "ELOOP"))
|
|
130
|
+
return { status: "failed", reason: "source_unavailable" };
|
|
131
|
+
throw sourceError;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (error instanceof SettingsConflict || fileCode(error, "ELOOP"))
|
|
135
|
+
return { status: "failed", reason: "target_conflict" };
|
|
136
|
+
throw error; // Unreadable is not absent; keep the admitted save unsettled.
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
if (!sourceIdentity)
|
|
140
|
+
return { status: "failed", reason: "target_conflict" };
|
|
141
|
+
const actualTarget = await this.verifiedIdentity(target, path, file);
|
|
142
|
+
if (!this.sameFile(sourceIdentity, actualTarget))
|
|
143
|
+
return { status: "failed", reason: "target_conflict" };
|
|
144
|
+
const actualSource = await this.downloadSourceIdentity(file);
|
|
145
|
+
return this.sameFile(sourceIdentity, actualSource) ? { status: "saved" } : { status: "failed", reason: "source_unavailable" };
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (error instanceof SettingsConflict || fileCode(error, "ENOENT") || fileCode(error, "ELOOP"))
|
|
149
|
+
return { status: "failed", reason: "source_unavailable" };
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
await target.close();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async handle(path, flags) {
|
|
157
|
+
const file = await open(path, flags | constants.O_NOFOLLOW, 0o600);
|
|
158
|
+
try {
|
|
159
|
+
if (!(await file.stat()).isFile())
|
|
160
|
+
throw new SettingsConflict("The selected item is not a file that Sui MCP can read.");
|
|
161
|
+
return file;
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
await file.close();
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async createUpload(file) {
|
|
169
|
+
if (file.role !== "upload" || file.invalidated || file.receivedSize !== 0 ||
|
|
170
|
+
file.relativePath !== `.settings-staging/${file.fileId}.upload`)
|
|
171
|
+
throw new SettingsConflict("This file transfer is no longer available. Choose the backup file again.");
|
|
172
|
+
await this.prepareDirectory("staging");
|
|
173
|
+
const handle = await this.handle(this.stagingPath(file), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
|
|
174
|
+
try {
|
|
175
|
+
await handle.sync();
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
await handle.close();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async writeReservedChunk(file, data) {
|
|
182
|
+
const reservation = file.reservation;
|
|
183
|
+
if (file.role !== "upload" || file.invalidated || file.sealed || !reservation || reservation.offset !== file.receivedSize ||
|
|
184
|
+
reservation.length !== data.length || reservation.sha256 !== settingsFileDigest(data) ||
|
|
185
|
+
reservation.offset + data.length > file.totalSize)
|
|
186
|
+
throw new SettingsConflict("The next part of the file does not match the expected content.");
|
|
187
|
+
await this.prepareDirectory("staging");
|
|
188
|
+
const handle = await this.handle(this.stagingPath(file), constants.O_RDWR);
|
|
189
|
+
try {
|
|
190
|
+
const length = (await handle.stat()).size;
|
|
191
|
+
if (length < reservation.offset || length > reservation.offset + data.length)
|
|
192
|
+
throw new SettingsConflict("The file size changed during transfer.");
|
|
193
|
+
// A crash may leave the beginning of this exact reserved chunk. Verify it
|
|
194
|
+
// before completing the remainder; never overwrite a different prefix.
|
|
195
|
+
const written = length - reservation.offset;
|
|
196
|
+
const prefix = Buffer.alloc(written);
|
|
197
|
+
const read = await handle.read(prefix, 0, written, reservation.offset);
|
|
198
|
+
if (read.bytesRead !== written || !prefix.equals(data.subarray(0, written)))
|
|
199
|
+
throw new SettingsConflict("The part of the file already received has changed.");
|
|
200
|
+
let offset = written;
|
|
201
|
+
while (offset < data.length) {
|
|
202
|
+
const result = await handle.write(data, offset, data.length - offset, reservation.offset + offset);
|
|
203
|
+
if (!result.bytesWritten)
|
|
204
|
+
throw new Error("Sui MCP could not save the next part of the file.");
|
|
205
|
+
offset += result.bytesWritten;
|
|
206
|
+
}
|
|
207
|
+
await handle.sync();
|
|
208
|
+
const actual = Buffer.alloc(data.length);
|
|
209
|
+
const verified = await handle.read(actual, 0, data.length, reservation.offset);
|
|
210
|
+
if (verified.bytesRead !== data.length || !actual.equals(data))
|
|
211
|
+
throw new SettingsConflict("The received part of the file could not be checked.");
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
await handle.close();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async readUpload(file) {
|
|
218
|
+
if (file.role !== "upload" || file.invalidated || file.receivedSize !== file.totalSize || file.reservation) {
|
|
219
|
+
throw new SettingsConflict("The complete file has not been received yet.");
|
|
220
|
+
}
|
|
221
|
+
await this.prepareDirectory("staging");
|
|
222
|
+
const handle = await this.handle(this.stagingPath(file), constants.O_RDONLY);
|
|
223
|
+
try {
|
|
224
|
+
if ((await handle.stat()).size !== file.totalSize)
|
|
225
|
+
throw new SettingsConflict("The received file size has changed.");
|
|
226
|
+
const data = Buffer.alloc(file.totalSize);
|
|
227
|
+
const actual = await handle.read(data, 0, data.length, 0);
|
|
228
|
+
if (actual.bytesRead !== data.length || settingsFileDigest(data) !== file.sha256)
|
|
229
|
+
throw new SettingsConflict("The received file has changed or is incomplete.");
|
|
230
|
+
return data;
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
await handle.close();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
async verifyUploadPrefix(file, offset, data) {
|
|
237
|
+
if (file.role !== "upload" || file.invalidated || offset < 0 || offset + data.length > file.receivedSize) {
|
|
238
|
+
throw new SettingsConflict("This part of the file has not been received.");
|
|
239
|
+
}
|
|
240
|
+
await this.prepareDirectory("staging");
|
|
241
|
+
const handle = await this.handle(this.stagingPath(file), constants.O_RDONLY);
|
|
242
|
+
try {
|
|
243
|
+
const actual = Buffer.alloc(data.length), read = await handle.read(actual, 0, data.length, offset);
|
|
244
|
+
if (read.bytesRead !== data.length || !actual.equals(data))
|
|
245
|
+
throw new SettingsConflict("Different file content was already received at this position.");
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
await handle.close();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async createDownload(file, data) {
|
|
252
|
+
if (!isSettingsDownloadFile(file) || file.invalidated || data.length !== file.totalSize || settingsFileDigest(data) !== file.sha256) {
|
|
253
|
+
throw new SettingsConflict("The download snapshot does not match the selected records.");
|
|
254
|
+
}
|
|
255
|
+
await this.prepareDirectory("staging");
|
|
256
|
+
let handle;
|
|
257
|
+
try {
|
|
258
|
+
handle = await this.handle(this.stagingPath(file), constants.O_RDWR | constants.O_CREAT | constants.O_EXCL);
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") {
|
|
262
|
+
throw new SettingsDownloadPathExists("The reserved backup location already contains a file.");
|
|
263
|
+
}
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
await handle.writeFile(data);
|
|
268
|
+
await handle.sync();
|
|
269
|
+
if ((await handle.stat()).size !== file.totalSize)
|
|
270
|
+
throw new SettingsConflict("The download snapshot length changed.");
|
|
271
|
+
const hash = createHash("sha256");
|
|
272
|
+
for await (const chunk of handle.createReadStream({ start: 0, autoClose: false }))
|
|
273
|
+
hash.update(chunk);
|
|
274
|
+
if (hash.digest("hex") !== file.sha256)
|
|
275
|
+
throw new SettingsConflict("The download snapshot content changed.");
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
await handle.close();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async readDownloadRange(file, offset, length) {
|
|
282
|
+
if (!isSettingsDownloadFile(file))
|
|
283
|
+
throw new SettingsConflict("This file is not the private download snapshot.");
|
|
284
|
+
return this.readVerifiedRange(file, this.directory("staging"), this.stagingPath(file), offset, length);
|
|
285
|
+
}
|
|
286
|
+
async verifyExport(file) {
|
|
287
|
+
const directory = this.directory("backups");
|
|
288
|
+
const info = await lstat(directory);
|
|
289
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
290
|
+
throw new SettingsConflict("The local backup directory changed.");
|
|
291
|
+
const handle = await this.handle(this.finalPath(file), constants.O_RDONLY);
|
|
292
|
+
try {
|
|
293
|
+
if ((await handle.stat()).size !== file.totalSize)
|
|
294
|
+
throw new SettingsConflict("The local backup length changed.");
|
|
295
|
+
const hash = createHash("sha256");
|
|
296
|
+
for await (const chunk of handle.createReadStream({ autoClose: false }))
|
|
297
|
+
hash.update(chunk);
|
|
298
|
+
if (hash.digest("hex") !== file.sha256)
|
|
299
|
+
throw new SettingsConflict("The local backup content changed.");
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
await handle.close();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
async readExportRange(file, offset, length) {
|
|
306
|
+
return this.readVerifiedRange(file, this.directory("backups"), this.finalPath(file), offset, length);
|
|
307
|
+
}
|
|
308
|
+
async readVerifiedRange(file, directory, path, offset, length) {
|
|
309
|
+
if (!file.sealed || file.invalidated || !Number.isSafeInteger(offset) || !Number.isSafeInteger(length) ||
|
|
310
|
+
offset < 0 || length < 1 || offset + length > file.totalSize)
|
|
311
|
+
throw new SettingsConflict("This part of the backup is unavailable.");
|
|
312
|
+
// Verify the artifact on the same open descriptor used for this chunk, so
|
|
313
|
+
// path replacement cannot substitute unchecked content between two opens.
|
|
314
|
+
const info = await lstat(directory);
|
|
315
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
316
|
+
throw new SettingsConflict("The local backup directory changed.");
|
|
317
|
+
const handle = await this.handle(path, constants.O_RDONLY);
|
|
318
|
+
try {
|
|
319
|
+
if ((await handle.stat()).size !== file.totalSize)
|
|
320
|
+
throw new SettingsConflict("The local backup length changed.");
|
|
321
|
+
const hash = createHash("sha256");
|
|
322
|
+
for await (const chunk of handle.createReadStream({ autoClose: false }))
|
|
323
|
+
hash.update(chunk);
|
|
324
|
+
if (hash.digest("hex") !== file.sha256)
|
|
325
|
+
throw new SettingsConflict("The local backup content changed.");
|
|
326
|
+
const data = Buffer.alloc(length), actual = await handle.read(data, 0, length, offset);
|
|
327
|
+
if (actual.bytesRead !== length)
|
|
328
|
+
throw new SettingsConflict("The local backup read was incomplete.");
|
|
329
|
+
return data;
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
await handle.close();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async cleanup(file) {
|
|
336
|
+
await this.prepareDirectory("staging");
|
|
337
|
+
try {
|
|
338
|
+
await unlink(this.stagingPath(file));
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT"))
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
// The public backup is the user's artifact and is never removed here.
|
|
345
|
+
}
|
|
346
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { SuiEndpointError } from "../suiEndpoint.js";
|
|
2
|
+
import { LocalSettingsError } from "../preferences/preferencesStore.js";
|
|
3
|
+
import { LocalDataError } from "../activity/localDataTypes.js";
|
|
4
|
+
export const SETTINGS_REPLACEMENT_BLOCKED_MESSAGE = "A wallet or transaction response is still pending, or the initial transaction-result waiting period has not ended. Local records cannot be replaced yet.";
|
|
5
|
+
// Only authenticated Settings validation reaches this mapping. Return fixed
|
|
6
|
+
// explanations and recovery categories, never imported values or exceptions.
|
|
7
|
+
export function settingsInputError(error, kind) {
|
|
8
|
+
if (!(error instanceof SuiEndpointError) &&
|
|
9
|
+
!(error instanceof LocalSettingsError && error.kind === "input_invalid") &&
|
|
10
|
+
!(error instanceof LocalDataError && error.kind === "input_invalid"))
|
|
11
|
+
return undefined;
|
|
12
|
+
const reason = error instanceof SuiEndpointError ? error.kind : error.details.endpointReason ?? error.details.reason;
|
|
13
|
+
const failure = (restoreReason, message) => ({ restoreReason, message });
|
|
14
|
+
if (kind === "import_local_data") {
|
|
15
|
+
if (reason === "invalid_url" || reason === "chain_identifier_mismatch")
|
|
16
|
+
return failure("invalid_backup", "A network address saved in this backup could not be verified for Sui mainnet. Choose another Sui MCP backup with valid mainnet network settings.");
|
|
17
|
+
if (reason === "endpoint_timeout" || reason === "endpoint_unreachable")
|
|
18
|
+
return failure("check_failed", "Sui MCP could not reach a network address saved in this backup. Check that the service is reachable, then check this backup again.");
|
|
19
|
+
}
|
|
20
|
+
switch (reason) {
|
|
21
|
+
case "invalid_url": return failure("invalid_backup", "The network address is not a valid URL. Correct it and select it again.");
|
|
22
|
+
case "chain_identifier_mismatch": return failure("invalid_backup", "The network check did not confirm Sui mainnet at this address. Select a Sui mainnet network address.");
|
|
23
|
+
case "endpoint_timeout": return failure("check_failed", "The service at this network address did not respond in time. Check that it is reachable, then review the change again.");
|
|
24
|
+
case "endpoint_unreachable": return failure("check_failed", "The service at this network address could not be reached. Check the address and your connection, then review the change again.");
|
|
25
|
+
case "wallet_request_unsettled": return failure("wallet_request_unsettled", SETTINGS_REPLACEMENT_BLOCKED_MESSAGE);
|
|
26
|
+
case "invalid_backup_encoding": return failure("invalid_backup", "This backup contains text that Sui MCP cannot read. Choose another backup.");
|
|
27
|
+
case "invalid_backup_json": return failure("invalid_backup", "This backup's contents are incomplete or incorrectly formatted. Choose another backup.");
|
|
28
|
+
}
|
|
29
|
+
if (error instanceof LocalDataError)
|
|
30
|
+
return failure("invalid_backup", "This file is not a supported Sui MCP backup. Choose another backup.");
|
|
31
|
+
if (error instanceof LocalSettingsError)
|
|
32
|
+
return failure("check_failed", "This network address could not be checked. Check the address, then review the change again.");
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|