@needmoretruth/nmts-cli 0.17.2

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.
Files changed (151) hide show
  1. package/AGENTS.md +591 -0
  2. package/LICENSE +202 -0
  3. package/LICENSING.md +49 -0
  4. package/README.ko.md +641 -0
  5. package/README.md +657 -0
  6. package/dist/account-proof.js +78 -0
  7. package/dist/account.js +75 -0
  8. package/dist/api-key.js +310 -0
  9. package/dist/api.js +271 -0
  10. package/dist/args.js +137 -0
  11. package/dist/artifact-about.js +70 -0
  12. package/dist/autonomy.js +98 -0
  13. package/dist/code-access.js +76 -0
  14. package/dist/code-vault.js +233 -0
  15. package/dist/collision.js +133 -0
  16. package/dist/commands/balance.js +93 -0
  17. package/dist/commands/consent.js +77 -0
  18. package/dist/commands/create.js +356 -0
  19. package/dist/commands/env.js +136 -0
  20. package/dist/commands/expiring.js +167 -0
  21. package/dist/commands/extend.js +236 -0
  22. package/dist/commands/get.js +130 -0
  23. package/dist/commands/kit.js +136 -0
  24. package/dist/commands/listfile.js +105 -0
  25. package/dist/commands/login.js +335 -0
  26. package/dist/commands/logout.js +33 -0
  27. package/dist/commands/ls.js +202 -0
  28. package/dist/commands/marks.js +176 -0
  29. package/dist/commands/mcp.js +139 -0
  30. package/dist/commands/mode.js +54 -0
  31. package/dist/commands/on-collision.js +45 -0
  32. package/dist/commands/organise.js +296 -0
  33. package/dist/commands/public-code.js +89 -0
  34. package/dist/commands/pull.js +185 -0
  35. package/dist/commands/push.js +278 -0
  36. package/dist/commands/put.js +282 -0
  37. package/dist/commands/rebuild.js +160 -0
  38. package/dist/commands/receive.js +125 -0
  39. package/dist/commands/recovery-list.js +104 -0
  40. package/dist/commands/recovery.js +291 -0
  41. package/dist/commands/s3.js +241 -0
  42. package/dist/commands/settings.js +28 -0
  43. package/dist/commands/share.js +276 -0
  44. package/dist/commands/sweep.js +240 -0
  45. package/dist/commands/trash.js +288 -0
  46. package/dist/commands/trial.js +264 -0
  47. package/dist/commands/update.js +169 -0
  48. package/dist/commands/usage.js +84 -0
  49. package/dist/commands/verify.js +331 -0
  50. package/dist/commands/wallet.js +122 -0
  51. package/dist/commands/whoami.js +33 -0
  52. package/dist/consent.js +200 -0
  53. package/dist/credentials.js +311 -0
  54. package/dist/crypto-surface.js +54 -0
  55. package/dist/crypto.js +137 -0
  56. package/dist/download-part.js +144 -0
  57. package/dist/download-sink.js +213 -0
  58. package/dist/download.js +163 -0
  59. package/dist/drive-paths.js +170 -0
  60. package/dist/environment.js +190 -0
  61. package/dist/errors.js +71 -0
  62. package/dist/exit.js +107 -0
  63. package/dist/expiry.js +115 -0
  64. package/dist/extend-chain.js +137 -0
  65. package/dist/extend-plan.js +149 -0
  66. package/dist/extend-sign.js +130 -0
  67. package/dist/guards.js +15 -0
  68. package/dist/help.js +192 -0
  69. package/dist/human-check.js +54 -0
  70. package/dist/item-trash.js +25 -0
  71. package/dist/kit-file.js +133 -0
  72. package/dist/list-file.js +71 -0
  73. package/dist/list-view-find.js +71 -0
  74. package/dist/list-view-order.js +96 -0
  75. package/dist/main.js +394 -0
  76. package/dist/manifest-create.js +81 -0
  77. package/dist/manifest-write.js +244 -0
  78. package/dist/manifest.js +213 -0
  79. package/dist/mark-render.js +45 -0
  80. package/dist/mcp-args.js +154 -0
  81. package/dist/mcp-tools/context.js +50 -0
  82. package/dist/mcp-tools/files.js +133 -0
  83. package/dist/mcp-tools/organise.js +128 -0
  84. package/dist/mcp-tools/reads.js +89 -0
  85. package/dist/mcp-tools/share.js +61 -0
  86. package/dist/mcp.js +142 -0
  87. package/dist/net-retry.js +84 -0
  88. package/dist/network.js +43 -0
  89. package/dist/notice.js +45 -0
  90. package/dist/product.js +38 -0
  91. package/dist/progress.js +110 -0
  92. package/dist/prompt.js +145 -0
  93. package/dist/rebuild.js +255 -0
  94. package/dist/recovery-assemble.js +148 -0
  95. package/dist/recovery-build.js +260 -0
  96. package/dist/recovery-map-file.js +114 -0
  97. package/dist/recovery-map.js +191 -0
  98. package/dist/recovery-release.js +229 -0
  99. package/dist/recovery-seq.js +63 -0
  100. package/dist/recovery-source.js +143 -0
  101. package/dist/registration.js +83 -0
  102. package/dist/s3/listing.js +134 -0
  103. package/dist/s3/multipart.js +104 -0
  104. package/dist/s3/response-sink.js +41 -0
  105. package/dist/s3/same-file.js +117 -0
  106. package/dist/s3/server.js +252 -0
  107. package/dist/s3/sigv4.js +168 -0
  108. package/dist/s3/staging.js +87 -0
  109. package/dist/s3/xml.js +80 -0
  110. package/dist/safe-path.js +89 -0
  111. package/dist/seal.js +206 -0
  112. package/dist/secret-reader.js +112 -0
  113. package/dist/server.js +33 -0
  114. package/dist/session.js +49 -0
  115. package/dist/setup-questions.js +31 -0
  116. package/dist/share.js +195 -0
  117. package/dist/shared/lib/crypto/size-padding.js +126 -0
  118. package/dist/shared/lib/drive/manifest-codec.js +287 -0
  119. package/dist/shared/lib/drive/manifest-index.js +235 -0
  120. package/dist/shared/lib/drive/manifest-ops.js +267 -0
  121. package/dist/shared/lib/drive/manifest-settings.js +65 -0
  122. package/dist/shared/lib/drive/name-conflict.js +121 -0
  123. package/dist/shared/lib/drive/unique-name.js +48 -0
  124. package/dist/shared/lib/extend/epochs.js +256 -0
  125. package/dist/shared/lib/net/retry-budget.js +95 -0
  126. package/dist/shared/lib/share/shared-file-info.js +65 -0
  127. package/dist/shared/lib/storage-network.js +64 -0
  128. package/dist/shared/lib/upload/part-plan.js +28 -0
  129. package/dist/stdout.js +109 -0
  130. package/dist/trash-sweep.js +123 -0
  131. package/dist/units.js +19 -0
  132. package/dist/update-check.js +164 -0
  133. package/dist/update-source.js +143 -0
  134. package/dist/upload-api.js +98 -0
  135. package/dist/upload-file.js +242 -0
  136. package/dist/upload-price.js +117 -0
  137. package/dist/upload-steps.js +111 -0
  138. package/dist/upload-store.js +252 -0
  139. package/dist/upload-wire.js +25 -0
  140. package/dist/upload.js +294 -0
  141. package/dist/usage-report.js +54 -0
  142. package/dist/wallet-chain.js +76 -0
  143. package/dist/wallet.js +209 -0
  144. package/dist/walrus-write.js +188 -0
  145. package/dist/walrus.js +156 -0
  146. package/package.json +56 -0
  147. package/vendor/nmts-crypto/nmts_crypto_wasm.d.ts +469 -0
  148. package/vendor/nmts-crypto/nmts_crypto_wasm.js +1300 -0
  149. package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm +0 -0
  150. package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm.d.ts +55 -0
  151. package/vendor/nmts-crypto/package.json +17 -0
@@ -0,0 +1,244 @@
1
+ // Writing the sealed file list — the step that turns a stored file into a visible one.
2
+ //
3
+ // ⛔ A FILE THAT IS NOT IN THIS LIST DOES NOT EXIST TO ITS OWNER. The server knows a file was
4
+ // committed and charges for it; the NAME, the folder and the key that opens it live only here,
5
+ // sealed under a key the server does not have. So this is the last step of an upload and the
6
+ // one that must not be skipped after the money moved.
7
+ //
8
+ // ⛔ THE WHOLE LIST IS REWRITTEN EVERY TIME. There is no "append" on the wire — the blob is sealed
9
+ // as one piece. That is why this re-reads immediately before writing: the version it builds on
10
+ // has to be the current one, and anything another device added since must be carried forward,
11
+ // not overwritten.
12
+ //
13
+ // ⛔ WHAT PROTECTS AGAINST BUILDING ON A STALE LIST. Three things, and none of them alone:
14
+ // · this machine's own record refuses a list older than one it already saw;
15
+ // · the server's compare-and-swap on `base_seq` refuses a write built on a version it has
16
+ // already moved past — which is what catches an ordinary race between two devices;
17
+ // · the `prev` link inside the blob makes a fork visible to the NEXT reader on any device.
18
+ // The account's own settings ride along for the same reason: they live in this blob or nowhere,
19
+ // so rewriting the list without them would silently clear them.
20
+ import { request, ServerError } from "./api.js";
21
+ import { AAD, DERIVED, loadCrypto } from "./crypto.js";
22
+ import { NmtsError } from "./errors.js";
23
+ import { readFileList, recordWrittenList } from "./manifest.js";
24
+ import { encodeManifest } from "./shared/lib/drive/manifest-codec.js";
25
+ import { buildIndex, entryAt, isLive, KIND_FILE, namesIn, normaliseName } from "./drive-paths.js";
26
+ import { decide } from "./collision.js";
27
+ import { applyIntents } from "./shared/lib/drive/manifest-ops.js";
28
+ import { uniqueFileName } from "./shared/lib/drive/unique-name.js";
29
+ /** How many times a lost compare-and-swap is re-applied before giving up. */
30
+ const CONFLICT_RETRIES = 3;
31
+ /**
32
+ * Apply one intent to the account's sealed file list.
33
+ *
34
+ * ⛔ THE INTENT IS WHAT IS RETRIED, NEVER FINISHED BYTES. On a lost compare-and-swap the list is
35
+ * read again and the intent is applied to the NEW one, so both edits survive. That is why the
36
+ * caller passes a function rather than an intent: an intent computed once against the old list
37
+ * could name a folder id, or a free name, that the new list no longer has.
38
+ *
39
+ * ⛔ AND THE INTENTS COME FROM THE BROWSER'S OWN MODULE, copied here byte-for-byte by
40
+ * `deploy/gen-cli-shared.mjs`. Re-implementing "send to the trash" would look trivial and be
41
+ * wrong in the small places: a re-trashed item must keep its ORIGINAL instant (it is the start
42
+ * of the 30-day window the product promises), and trashing a folder must not stamp its
43
+ * children (that would reset each child's own clock).
44
+ *
45
+ * `make` returning null means there is nothing to do; nothing is written and `changed` is false.
46
+ */
47
+ export async function applyToList(input, make) {
48
+ // ⛔ ONE COMPARE-AND-SWAP LOOP IN THIS FILE, and this is the one-intent door into it. A second
49
+ // copy would be a second place for "decide again on every attempt" to be got right, and the
50
+ // copy nobody re-reads is the one that quietly re-applies a stale decision.
51
+ return applyManyToList(input, (entries) => {
52
+ const intent = make(entries);
53
+ return intent === null ? [] : [intent];
54
+ });
55
+ }
56
+ /**
57
+ * Apply a RUN of intents to the account's sealed file list — as ONE write.
58
+ *
59
+ * ⛔ ONE WRITE, NOT ONE PER TARGET. A command naming five things and writing five times is five
60
+ * chances to lose the compare-and-swap, and losing it half way leaves a drive nobody asked
61
+ * for: three things moved, two not, and one error that names neither half. The whole list is
62
+ * rewritten on every save anyway (see the header), so five edits cost exactly what one costs.
63
+ *
64
+ * ⛔ AND `make` DECIDES THE WHOLE RUN AGAIN ON EVERY ATTEMPT. A free name, an existing folder and
65
+ * a live target are all facts about the version that was READ, and a retry happens against a
66
+ * version somebody else has just written. A `make` that folds its own intents onto a working
67
+ * copy as it goes must start that fold from the list it is handed each time — never from the
68
+ * working copy it built on the attempt before.
69
+ *
70
+ * An empty run means there is nothing to do; nothing is written and `changed` is false.
71
+ */
72
+ export async function applyManyToList(input, make) {
73
+ const crypt = await loadCrypto();
74
+ const [from, to] = DERIVED.fileListKey;
75
+ const derived = crypt.kdf_derive(crypt.account_code_parse(input.code));
76
+ const key = derived.slice(from, to);
77
+ derived.fill(0);
78
+ try {
79
+ let conflicted = false;
80
+ for (let attempt = 0; attempt <= CONFLICT_RETRIES; attempt += 1) {
81
+ const current = await readFileList(input.server, input.apiKey, input.code, input.accountId);
82
+ const entries = current.manifest ? current.manifest.entries : [];
83
+ const intents = make(entries);
84
+ // ⛔ A no-op is a SUCCESS, not a failure. Renaming a file to the name it already has, or
85
+ // trashing something already in the trash, must not cost a version bump every other
86
+ // device then has to download.
87
+ if (intents.length === 0) {
88
+ return { seq: current.seq ?? 0, reappliedAfterConflict: conflicted, changed: false, entries };
89
+ }
90
+ const next = applyIntents(entries, intents);
91
+ if (next === entries) {
92
+ return { seq: current.seq ?? 0, reappliedAfterConflict: conflicted, changed: false, entries };
93
+ }
94
+ const body = await encodeManifest(next, (current.seq ?? 0) + 1, current.fingerprint, current.manifest?.settings);
95
+ const sealed = crypt.envelope_seal(key, new TextEncoder().encode(AAD.fileList), body);
96
+ body.fill(0);
97
+ const ct = Buffer.from(sealed).toString("base64url");
98
+ try {
99
+ const answer = await request(input.server, "/v1/manifest", {
100
+ method: "PUT",
101
+ token: input.apiKey,
102
+ body: { base_seq: current.seq ?? null, ct },
103
+ });
104
+ const seq = seqOf(answer);
105
+ await recordWrittenList(input.accountId, seq, ct);
106
+ return { seq, reappliedAfterConflict: conflicted, changed: true, entries: next };
107
+ }
108
+ catch (error) {
109
+ // ⛔ A version conflict is an ORDINARY outcome, not a failure: another device wrote first.
110
+ // Anything else is not, and must not be retried into a second attempt at the same edit.
111
+ if (!(error instanceof ServerError) || error.code !== "VERSION_CONFLICT")
112
+ throw error;
113
+ conflicted = true;
114
+ }
115
+ }
116
+ throw new NmtsError(`The file list was rewritten by something else ${CONFLICT_RETRIES + 1} times in a row.`, {
117
+ nextStep: "Nothing about this edit was lost — running the same command again applies it to the " +
118
+ "list as it now stands.",
119
+ });
120
+ }
121
+ finally {
122
+ key.fill(0);
123
+ }
124
+ }
125
+ /**
126
+ * The entries a run of typed paths names, in the order they were typed.
127
+ *
128
+ * ⛔ HERE, BESIDE THE BATCH WRITE, so every command that takes many paths answers "the same thing
129
+ * named twice" the same way: once. `nmts rm a.txt a.txt` is not two deletions, and a repeated
130
+ * id inside one intent would make the count in the message disagree with the list written.
131
+ *
132
+ * ⛔ AND A PATH THAT DOES NOT RESOLVE REFUSES THE WHOLE RUN, because it throws from here before
133
+ * anything is composed. That is the decision every batch command in this tool makes: nothing
134
+ * is half-done. Moving the four paths that resolved and skipping the fifth would exit 0 on a
135
+ * command that did not do what it was told, and the caller would have to diff the drive to
136
+ * find out which one. A path already IN the state being asked for is not this case — that is
137
+ * a no-op, and each command names it in its own words.
138
+ *
139
+ * ⚠ Call it INSIDE `make`. A path is a question about the list, and the answer changes when
140
+ * another device writes first.
141
+ */
142
+ export function batchTargets(entries, paths, options = {}) {
143
+ const out = [];
144
+ const seen = new Set();
145
+ for (const path of paths) {
146
+ const found = entryAt(entries, path, options);
147
+ if (seen.has(found.id))
148
+ continue;
149
+ seen.add(found.id);
150
+ out.push(found);
151
+ }
152
+ return out;
153
+ }
154
+ /**
155
+ * Decide what adding this entry does — the whole of the collision rule, with no server in it.
156
+ *
157
+ * ⛔ IT IS RE-RUN ON EVERY COMPARE-AND-SWAP ATTEMPT, so everything it looks at has to come from
158
+ * the `entries` it is handed. A free name, a live holder and a folder id are all facts about
159
+ * the version that was READ, and a retry happens against a version somebody else just wrote.
160
+ *
161
+ * ⛔ ONLY A LIVE FILE IS DISPLACED. A folder can hold the name, and replacing one would mean
162
+ * deleting it and everything under it in order to store a single file. A trashed file holds its
163
+ * name too, and displacing THAT would destroy something already on its way out for a name the
164
+ * person can no longer see. Both are renamed around, with no answer consulted — which is what
165
+ * happened to every collision before anything could be answered at all.
166
+ */
167
+ export function planAddition(entries, entry,
168
+ /**
169
+ * The ANSWER, already settled — not what a run asked for.
170
+ *
171
+ * ⛔ WHO IS ALLOWED TO SAY "OVERWRITE" IS `collision.ts`'s JOB, not this one's. It weighs the
172
+ * machine's stored answer, what the run asked for, and whether a mode lets an agent decide
173
+ * for itself. Re-deriving any of that here would be a second place for the owner's rule to
174
+ * live, and the copy nobody re-reads is the one that quietly disagrees.
175
+ */
176
+ choice, now = Date.now()) {
177
+ // ⛔ An id already in the list is not added twice: the account would show two rows for one file
178
+ // and the second would be unreachable. It is also how a re-run of an interrupted upload finds
179
+ // its own work already done.
180
+ const existing = entries.find((e) => e.id === entry.id);
181
+ if (existing !== undefined)
182
+ return { name: existing.name, alreadyThere: existing.name, intents: [] };
183
+ const folded = normaliseName(entry.name);
184
+ const index = buildIndex(entries);
185
+ const holder = entries.find((e) => e.parentId === entry.parentId &&
186
+ e.kind === KIND_FILE &&
187
+ normaliseName(e.name) === folded &&
188
+ isLive(index, e));
189
+ if (holder !== undefined && choice === "overwrite") {
190
+ return {
191
+ name: entry.name,
192
+ replaced: { id: holder.id, name: holder.name },
193
+ intents: [
194
+ { op: "trash", ids: [holder.id], at: now },
195
+ { op: "add", entry },
196
+ ],
197
+ };
198
+ }
199
+ const name = uniqueFileName(entry.name, namesIn(entries, entry.parentId));
200
+ return { name, intents: [{ op: "add", entry: { ...entry, name } }] };
201
+ }
202
+ /**
203
+ * Add one entry to the account's sealed file list.
204
+ *
205
+ * ⛔ THE NAME IS CHOSEN AGAINST THE LIST AS IT IS ON THIS ATTEMPT. That is the reason this passes
206
+ * a function to `applyManyToList`: after a lost compare-and-swap the free names have changed,
207
+ * and a name picked against the old list could land on top of what the other device just added.
208
+ * The collision is judged again on every attempt for the same reason.
209
+ *
210
+ * ⛔ ONLY A LIVE FILE IS DISPLACED. A folder can hold the name, and replacing one would mean
211
+ * deleting it and everything under it in order to store a single file. Those are renamed, with
212
+ * no answer consulted, exactly as every collision was before anything could be answered at all.
213
+ */
214
+ export async function addEntry(input) {
215
+ let name = input.entry.name;
216
+ let alreadyThere = null;
217
+ let replaced;
218
+ // ⛔ SETTLED ONCE, OUTSIDE THE RETRY LOOP. What the machine is set to and whether a mode is on
219
+ // are facts about this run, not about the list version a compare-and-swap happened to read.
220
+ const choice = decide(input.onCollision).choice;
221
+ const result = await applyManyToList(input, (entries) => {
222
+ const plan = planAddition(entries, input.entry, choice);
223
+ alreadyThere = plan.alreadyThere ?? null;
224
+ name = plan.name;
225
+ replaced = plan.replaced;
226
+ return plan.intents;
227
+ });
228
+ return {
229
+ seq: result.seq,
230
+ name: alreadyThere ?? name,
231
+ reappliedAfterConflict: result.reappliedAfterConflict,
232
+ ...(replaced ? { replaced } : {}),
233
+ };
234
+ }
235
+ function seqOf(answer) {
236
+ if (typeof answer === "object" && answer !== null) {
237
+ const seq = Reflect.get(answer, "seq");
238
+ if (typeof seq === "number" && Number.isSafeInteger(seq) && seq >= 1)
239
+ return seq;
240
+ }
241
+ throw new NmtsError("The file list was written but the server did not say which version it is now.", {
242
+ nextStep: "The entry is saved. Run `nmts ls` to see it.",
243
+ });
244
+ }
@@ -0,0 +1,213 @@
1
+ // Reading the sealed file list: fetch it, open it, and notice when the server hands back an old one.
2
+ //
3
+ // ⛔ THE SERVER CAN LIE ABOUT THE VERSION, AND THAT IS DESIGNED FOR. The version number lives in a
4
+ // server column AND inside the sealed blob (NCF-3 §6.1). The sealed one is authenticated; the
5
+ // column is not. So the column is read as a hint and the sealed one is what is believed, and
6
+ // when they disagree this module says so rather than picking one quietly.
7
+ //
8
+ // ⛔ WHAT A SINGLE READ CANNOT SEE. "Never go backwards" is only checkable against something this
9
+ // machine already saw. A first run has nothing to compare to and is therefore trusting; every
10
+ // run after it is not. That is why the last version and the blob's fingerprint are written down
11
+ // — a tool that forgets cannot tell a rollback from a fresh start, and it would never say so.
12
+ //
13
+ // ⚠ THE RECORD IS NOT A SECRET. It holds an account id, a version number and a hash of ciphertext.
14
+ // It is still written 0600, because it sits beside a file that IS a secret and one mode is
15
+ // easier to keep right than two.
16
+ //
17
+ // ⛔ AND THE SEALED BYTES THEMSELVES ARE KEPT, beside that record. The record alone is a detector:
18
+ // it can tell that a list went backwards, and it cannot hand anybody a list. The blob can — it
19
+ // is the account's names, folders and file keys, sealed with the account code, and a copy of it
20
+ // on this machine is one of the two things a person needs when the server has nothing to give
21
+ // them. A tool that read the list on every run and then threw it away left an account used only
22
+ // from a terminal with neither. It is written with the record, by the one function that writes
23
+ // either, so the two can never describe different versions.
24
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { request } from "./api.js";
27
+ import { AAD, DERIVED, loadCrypto } from "./crypto.js";
28
+ import { configDir } from "./credentials.js";
29
+ import { NmtsError } from "./errors.js";
30
+ import { decodeManifest } from "./shared/lib/drive/manifest-codec.js";
31
+ function asResponse(value) {
32
+ if (typeof value !== "object" || value === null)
33
+ throw new NmtsError("The server's answer was not an object.");
34
+ const v = value;
35
+ if (v["state"] === "absent")
36
+ return { state: "absent" };
37
+ if (v["state"] === "present" &&
38
+ typeof v["seq"] === "number" &&
39
+ typeof v["ct"] === "string" &&
40
+ typeof v["updated_at"] === "string") {
41
+ return { state: "present", seq: v["seq"], ct: v["ct"], updated_at: v["updated_at"] };
42
+ }
43
+ throw new NmtsError("The server answered with a file list this version cannot read.", {
44
+ nextStep: "Update this tool, or open the account in a browser to see what is there.",
45
+ });
46
+ }
47
+ /** base64url SHA-256 of a sealed blob — the value a later list carries as its `prev`. */
48
+ async function fingerprint(ct) {
49
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(ct));
50
+ return Buffer.from(digest).toString("base64url");
51
+ }
52
+ function statePath() {
53
+ return join(configDir(), "file-list-state.json");
54
+ }
55
+ /**
56
+ * Where this machine keeps one account's sealed file list.
57
+ *
58
+ * ⛔ AN ACCOUNT ID BECOMES PART OF A PATH HERE, so it is CHECKED rather than trusted. Every id this
59
+ * tool has comes from its own derivation and is base64url, but a value that reaches a path join
60
+ * unchecked is how `..` becomes a write somewhere else, and the check costs one line.
61
+ */
62
+ function keptListPath(accountId) {
63
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(accountId)) {
64
+ throw new NmtsError("That is not an account id this tool derived.", {
65
+ nextStep: "Nothing was written. This is a fault in the tool rather than in the account.",
66
+ });
67
+ }
68
+ return join(configDir(), `file-list-${accountId}.json`);
69
+ }
70
+ function isKeptList(value) {
71
+ if (typeof value !== "object" || value === null)
72
+ return false;
73
+ return (typeof Reflect.get(value, "seq") === "number" &&
74
+ typeof Reflect.get(value, "savedAt") === "string" &&
75
+ typeof Reflect.get(value, "ct") === "string");
76
+ }
77
+ /**
78
+ * The copy this machine holds for an account, or null when it holds none.
79
+ *
80
+ * ⚠ A COPY THAT CANNOT BE READ IS REPORTED AS NO COPY, on purpose. There is nothing to salvage
81
+ * from a truncated one, the next read of the list replaces it, and a command that refused to
82
+ * write out a good copy because an old one is unreadable would be refusing the very thing it is
83
+ * for.
84
+ */
85
+ export function readKeptList(accountId) {
86
+ try {
87
+ const parsed = JSON.parse(readFileSync(keptListPath(accountId), "utf8"));
88
+ return isKeptList(parsed) ? parsed : null;
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ /**
95
+ * Keep these sealed bytes as this machine's copy.
96
+ *
97
+ * ⛔ NEWER ONLY. A copy is replaced when the version landing is at least the one already kept, and
98
+ * never when it is older: this machine's record of what it has seen can be lost or cleared, and
99
+ * a run that then read an older list from the server must not overwrite the newest copy with
100
+ * it. The record refuses a lower version while it exists; this is what holds when it does not.
101
+ */
102
+ function writeKept(accountId, seq, ct) {
103
+ const held = readKeptList(accountId);
104
+ if (held !== null && held.seq > seq)
105
+ return;
106
+ const copy = { seq, savedAt: new Date().toISOString(), ct };
107
+ mkdirSync(configDir(), { recursive: true, mode: 0o700 });
108
+ writeFileSync(keptListPath(accountId), `${JSON.stringify(copy, null, 2)}\n`, { mode: 0o600 });
109
+ }
110
+ function readSeen() {
111
+ try {
112
+ const parsed = JSON.parse(readFileSync(statePath(), "utf8"));
113
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
114
+ }
115
+ catch {
116
+ // A missing or unreadable record means "nothing to compare against", which is the same
117
+ // position a first run is in. It is not an error and must not stop a listing.
118
+ return {};
119
+ }
120
+ }
121
+ /**
122
+ * Write down what this machine has seen: the version, the fingerprint, and the bytes.
123
+ *
124
+ * ⛔ ONE FUNCTION FOR BOTH. The detector and the copy describe the same blob, and two functions
125
+ * would be two chances for a caller to update one and not the other — after which the machine
126
+ * would hold a copy of one version while claiming to have seen another.
127
+ */
128
+ function writeSeen(accountId, seq, fp, ct) {
129
+ const all = readSeen();
130
+ all[accountId] = { seq, fingerprint: fp };
131
+ mkdirSync(configDir(), { recursive: true, mode: 0o700 });
132
+ writeFileSync(statePath(), `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
133
+ // The record goes first: it is the safety device, and a machine that failed to keep a copy must
134
+ // still refuse an older list afterwards.
135
+ writeKept(accountId, seq, ct);
136
+ }
137
+ /**
138
+ * Record a version this machine WROTE, so the server cannot serve an older one back afterwards.
139
+ *
140
+ * ⛔ ONLY AFTER THE SERVER ACCEPTED IT. Recording a version that lost the compare-and-swap would
141
+ * leave this machine believing in a list that never existed — and then refusing the real one as
142
+ * a rollback.
143
+ */
144
+ export async function recordWrittenList(accountId, seq, ct) {
145
+ writeSeen(accountId, seq, await fingerprint(ct), ct);
146
+ }
147
+ /** True when this machine has a record for the account — i.e. a rollback would be visible. */
148
+ export function hasSeenBefore(accountId) {
149
+ return existsSync(statePath()) && accountId in readSeen();
150
+ }
151
+ /**
152
+ * Fetch and open the account's file list.
153
+ *
154
+ * `accountCode` is used here and not kept: the file-list key is derived, used, and zeroed. The
155
+ * derivation output holds every other key in the account, so it does not outlive this call.
156
+ */
157
+ export async function readFileList(base, apiKey, accountCode, accountId) {
158
+ const answer = asResponse(await request(base, "/v1/manifest", { token: apiKey }));
159
+ const first = !hasSeenBefore(accountId);
160
+ if (answer.state === "absent")
161
+ return { manifest: null, firstTimeOnThisMachine: first };
162
+ const seen = readSeen()[accountId];
163
+ if (seen !== undefined && answer.seq < seen.seq) {
164
+ throw new NmtsError(`The server offered file-list version ${answer.seq}; this machine already saw ${seen.seq}.`, {
165
+ nextStep: "Nothing was changed. A list that goes backwards means an older copy is being served, " +
166
+ "so files added since could be missing from it. Open the account in a browser and " +
167
+ "compare before writing anything.",
168
+ });
169
+ }
170
+ // ⛔ The same version must be the same bytes. Two different blobs at one version number is a
171
+ // FORK — two devices shown different histories — and it is the one fork check an occasional
172
+ // reader can actually make. (The chain link inside the list, `prev`, names the blob its
173
+ // author continued from; between two runs of this tool a browser may have written several
174
+ // versions, so `prev` will legitimately name a blob this machine never saw. Comparing it
175
+ // would cry wolf on ordinary use, which is worse than not comparing it.)
176
+ const fp = await fingerprint(answer.ct);
177
+ if (seen !== undefined && answer.seq === seen.seq && fp !== seen.fingerprint) {
178
+ throw new NmtsError(`The server offered a different file list at the same version (${answer.seq}).`, {
179
+ nextStep: "Nothing was changed. One version number can only have one list, so this machine is " +
180
+ "being shown a different history than it was before. Open the account in a browser " +
181
+ "and compare before writing anything.",
182
+ });
183
+ }
184
+ const crypt = await loadCrypto();
185
+ const [from, to] = DERIVED.fileListKey;
186
+ const derived = crypt.kdf_derive(crypt.account_code_parse(accountCode));
187
+ const key = derived.slice(from, to);
188
+ derived.fill(0);
189
+ let body;
190
+ try {
191
+ body = crypt.envelope_open(key, new TextEncoder().encode(AAD.fileList), Buffer.from(answer.ct, "base64url"));
192
+ }
193
+ catch {
194
+ throw new NmtsError("The file list did not open with this account's key.", {
195
+ nextStep: "Either the code belongs to a different account, or the stored bytes are not what this " +
196
+ "account sealed. Nothing was changed.",
197
+ });
198
+ }
199
+ finally {
200
+ key.fill(0);
201
+ }
202
+ const manifest = await decodeManifest(body);
203
+ body.fill(0);
204
+ const out = { manifest, seq: manifest.seq, fingerprint: fp, firstTimeOnThisMachine: first };
205
+ // ⛔ The sealed number is the authenticated one, so it is what gets recorded and what a later
206
+ // run compares against. The column is reported when it differs and otherwise ignored.
207
+ if (manifest.seq !== answer.seq)
208
+ out.serverSeqDisagreed = answer.seq;
209
+ // ⛔ THE VERSION THAT IS KEPT IS THE SEALED ONE, and so are the bytes it came out of. Believing
210
+ // the server's column here would let it decide which copy this machine keeps.
211
+ writeSeen(accountId, manifest.seq, fp, answer.ct);
212
+ return out;
213
+ }
@@ -0,0 +1,45 @@
1
+ // How the three marks a person puts on their own files are SHOWN, in one place — so `ls` and the
2
+ // commands that set them cannot end up disagreeing about what "starred" looks like.
3
+ //
4
+ // ⛔ THE MARKS LIVE ONLY IN THE SEALED FILE LIST. The server is never told that a file is starred,
5
+ // what a label is called, or how many there are: it holds the blob and cannot open it. So
6
+ // nothing here asks a server anything — it reads the list this account has already opened.
7
+ //
8
+ // ⛔ A SUFFIX, NOT A COLUMN. `ls` pads one width for the path and puts `[trash, …]` after the
9
+ // size; a marks column would need a second computed width and would print an empty one on
10
+ // every account that uses no marks, which is most of them. The table is for a person glancing
11
+ // down it, and the field an agent parses is in `--json`, where it is always present.
12
+ /**
13
+ * The marks on one entry — always all three.
14
+ *
15
+ * ⚠ ABSENT IS `false` HERE AND ABSENCE IN THE LIST, and the difference is deliberate. The format
16
+ * writes a mark only when it is on, because the whole list is re-sealed on every change and
17
+ * re-downloaded on every cold start. A reader parsing this needs the opposite: a field that is
18
+ * always there, so "this file is not starred" cannot be mistaken for "this output does not say".
19
+ */
20
+ export function marksOf(entry) {
21
+ return {
22
+ favorite: entry.favorite === true,
23
+ pinned: entry.pinned === true,
24
+ labels: entry.labels ?? [],
25
+ };
26
+ }
27
+ /**
28
+ * What `ls` puts after a row, or an empty string when the entry wears no mark.
29
+ *
30
+ * ⚠ It carries its own leading spaces, exactly like the trash suffix beside it, so a row with no
31
+ * marks is byte-for-byte the row that was printed before marks existed.
32
+ */
33
+ export function markSuffix(marks) {
34
+ const parts = [];
35
+ if (marks.favorite)
36
+ parts.push("starred");
37
+ if (marks.pinned)
38
+ parts.push("pinned");
39
+ // ⚠ QUOTED, because a label is free text somebody typed. One label called `work, home` and two
40
+ // labels called `work` and `home` would otherwise print as the same line.
41
+ if (marks.labels.length > 0) {
42
+ parts.push(`labels: ${marks.labels.map((l) => `"${l}"`).join(", ")}`);
43
+ }
44
+ return parts.length === 0 ? "" : ` [${parts.join(", ")}]`;
45
+ }
@@ -0,0 +1,154 @@
1
+ // Checking a tool call's arguments against the schema the tool advertised.
2
+ //
3
+ // ⛔ WHY THIS EXISTS: THE SCHEMA WAS DECORATION. Every tool declares an `inputSchema` and the
4
+ // protocol hands it to the model, but nothing on this side ever compared a call against it. The
5
+ // transport coerced anything that was not an object to `{}` and passed the rest straight
6
+ // through, so `required`, the declared types and `additionalProperties: false` were all
7
+ // advertised and none of them held. Each tool then hand-checked the one string it could not do
8
+ // without, and checked booleans with `=== true` — which means a model sending `"dry_run": "true"`
9
+ // (the string, which is what a model that has been told to send JSON text produces) got a real,
10
+ // paid upload while believing it had asked for a price. That is the failure this file exists to
11
+ // stop, and it is worth a file: the alternative is the same four checks written again inside
12
+ // every tool, where the twentieth one will forget.
13
+ //
14
+ // ⛔ IT REFUSES; IT DOES NOT REPAIR. A wrong argument is answered with a message naming what was
15
+ // wrong, never with a guess at what was meant. Coercing `"true"` to `true` would be deciding on
16
+ // the caller's behalf that they wanted the branch that spends money, which is exactly the
17
+ // decision that must not be made here. The model can read the refusal and call again.
18
+ //
19
+ // ⚠ IT UNDERSTANDS THE SUBSET THIS TOOL USES, AND SAYS SO WHEN IT DOES NOT. The schemas here are
20
+ // flat objects of scalars and arrays of scalars. A keyword outside that subset is not silently
21
+ // ignored: `unsupported()` lists what a schema asked for that this checker cannot judge, so a
22
+ // schema that grows past it fails a test rather than quietly losing its guarantee.
23
+ import { isRecord } from "./guards.js";
24
+ /** The JSON types a declared property may have. */
25
+ const KNOWN_TYPES = ["string", "number", "integer", "boolean", "array", "object"];
26
+ /** Keywords this checker actually enforces. Anything else in a schema is reported, not ignored. */
27
+ const HANDLED_ROOT = ["type", "properties", "required", "additionalProperties", "description"];
28
+ const HANDLED_PROPERTY = ["type", "description", "items", "enum"];
29
+ function stringList(value) {
30
+ return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
31
+ }
32
+ /** What a value actually is, in the vocabulary the schema uses. */
33
+ function actualType(value) {
34
+ if (Array.isArray(value))
35
+ return "array";
36
+ if (value === null)
37
+ return "null";
38
+ if (typeof value === "number")
39
+ return Number.isInteger(value) ? "integer" : "number";
40
+ return typeof value;
41
+ }
42
+ function typeMatches(declared, value) {
43
+ switch (declared) {
44
+ case "string":
45
+ return typeof value === "string";
46
+ case "boolean":
47
+ return typeof value === "boolean";
48
+ case "number":
49
+ return typeof value === "number" && Number.isFinite(value);
50
+ case "integer":
51
+ return typeof value === "number" && Number.isInteger(value);
52
+ case "array":
53
+ return Array.isArray(value);
54
+ case "object":
55
+ return isRecord(value);
56
+ }
57
+ }
58
+ function declaredType(property) {
59
+ if (!isRecord(property))
60
+ return null;
61
+ const t = property["type"];
62
+ return KNOWN_TYPES.find((k) => k === t) ?? null;
63
+ }
64
+ /**
65
+ * Keywords a schema uses that this checker does not enforce.
66
+ *
67
+ * ⛔ THE POINT IS THAT IT IS NOT EMPTY-BY-ASSUMPTION. A checker that silently skips what it does
68
+ * not understand still returns "no problems", and a schema that grew a `minimum` or a `oneOf`
69
+ * would go on being advertised while nothing held it. A test compares this against the real
70
+ * tool table, so growing a schema past this file turns something red.
71
+ */
72
+ export function unsupported(schema) {
73
+ if (!isRecord(schema))
74
+ return ["the schema is not an object"];
75
+ const found = [];
76
+ for (const key of Object.keys(schema)) {
77
+ if (!HANDLED_ROOT.includes(key))
78
+ found.push(key);
79
+ }
80
+ const properties = schema["properties"];
81
+ if (isRecord(properties)) {
82
+ for (const [name, property] of Object.entries(properties)) {
83
+ if (!isRecord(property)) {
84
+ found.push(`${name} (not an object)`);
85
+ continue;
86
+ }
87
+ if (declaredType(property) === null)
88
+ found.push(`${name}.type`);
89
+ for (const key of Object.keys(property)) {
90
+ if (!HANDLED_PROPERTY.includes(key))
91
+ found.push(`${name}.${key}`);
92
+ }
93
+ }
94
+ }
95
+ return found;
96
+ }
97
+ /**
98
+ * Compare one call's arguments against the schema its tool advertised.
99
+ *
100
+ * Returns the problems, most important first: a missing required argument before a wrong type,
101
+ * because a caller that forgot one is usually about to be told about the other for the same reason.
102
+ * An empty array means the call may proceed.
103
+ */
104
+ export function checkArgs(schema, args) {
105
+ if (!isRecord(args)) {
106
+ return [`arguments must be an object, and this call sent ${actualType(args)}`];
107
+ }
108
+ if (!isRecord(schema))
109
+ return [];
110
+ const properties = isRecord(schema["properties"]) ? schema["properties"] : {};
111
+ const required = stringList(schema["required"]);
112
+ const closed = schema["additionalProperties"] === false;
113
+ const problems = [];
114
+ for (const name of required) {
115
+ if (!(name in args) || args[name] === undefined)
116
+ problems.push(`\`${name}\` is required`);
117
+ }
118
+ for (const [name, value] of Object.entries(args)) {
119
+ // ⚠ `undefined` cannot come off the wire — JSON has no such value — so a key holding it was
120
+ // built locally and means "not given". Treating it as given would refuse calls the caller
121
+ // made correctly.
122
+ if (value === undefined)
123
+ continue;
124
+ const property = properties[name];
125
+ if (property === undefined) {
126
+ if (closed)
127
+ problems.push(`\`${name}\` is not an argument this tool takes`);
128
+ continue;
129
+ }
130
+ const type = declaredType(property);
131
+ if (type === null)
132
+ continue;
133
+ if (!typeMatches(type, value)) {
134
+ problems.push(`\`${name}\` must be ${type}, and this call sent ${actualType(value)}`);
135
+ continue;
136
+ }
137
+ if (type === "array" && Array.isArray(value) && isRecord(property) && isRecord(property["items"])) {
138
+ const itemType = declaredType(property["items"]);
139
+ if (itemType !== null) {
140
+ const wrong = value.findIndex((item) => !typeMatches(itemType, item));
141
+ if (wrong !== -1) {
142
+ problems.push(`\`${name}\` must hold ${itemType} values, and item ${wrong + 1} is ${actualType(value[wrong])}`);
143
+ }
144
+ }
145
+ }
146
+ if (isRecord(property)) {
147
+ const allowed = stringList(property["enum"]);
148
+ if (allowed.length > 0 && typeof value === "string" && !allowed.includes(value)) {
149
+ problems.push(`\`${name}\` must be one of ${allowed.join(", ")} — this call sent ${value}`);
150
+ }
151
+ }
152
+ }
153
+ return problems;
154
+ }