aipou-mcp-server 0.4.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.
Files changed (66) hide show
  1. package/NOTICE +8 -0
  2. package/README.md +18 -0
  3. package/dist/bounded-input.d.ts +8 -0
  4. package/dist/bounded-input.js +21 -0
  5. package/dist/bounded-input.test.d.ts +1 -0
  6. package/dist/bounded-input.test.js +24 -0
  7. package/dist/claims.js +119 -31
  8. package/dist/doctor.d.ts +32 -0
  9. package/dist/doctor.js +112 -0
  10. package/dist/doctor.test.d.ts +1 -0
  11. package/dist/doctor.test.js +164 -0
  12. package/dist/evidence.d.ts +21 -0
  13. package/dist/evidence.js +14 -0
  14. package/dist/feedback.d.ts +17 -0
  15. package/dist/feedback.js +24 -0
  16. package/dist/identity.d.ts +3 -0
  17. package/dist/identity.js +25 -1
  18. package/dist/index.js +15121 -106
  19. package/dist/init.d.ts +8 -2
  20. package/dist/init.js +20 -7
  21. package/dist/init.test.js +18 -4
  22. package/dist/lock.js +47 -9
  23. package/dist/lock.test.d.ts +1 -0
  24. package/dist/lock.test.js +26 -0
  25. package/dist/observed-outcome-pilot.d.ts +16 -0
  26. package/dist/observed-outcome-pilot.js +59 -0
  27. package/dist/observed-outcome-pilot.test.d.ts +1 -0
  28. package/dist/observed-outcome-pilot.test.js +25 -0
  29. package/dist/paybox-link.d.ts +34 -0
  30. package/dist/paybox-link.js +52 -0
  31. package/dist/paybox-link.test.d.ts +1 -0
  32. package/dist/paybox-link.test.js +36 -0
  33. package/dist/private-json.d.ts +2 -0
  34. package/dist/private-json.js +39 -0
  35. package/dist/protocol.test.js +23 -0
  36. package/dist/receipt-metadata.d.ts +13 -0
  37. package/dist/receipt-metadata.js +28 -0
  38. package/dist/receipt-metadata.test.d.ts +1 -0
  39. package/dist/receipt-metadata.test.js +39 -0
  40. package/dist/receipt-verifier.d.ts +13 -0
  41. package/dist/receipt-verifier.js +125 -0
  42. package/dist/receipt-verifier.test.d.ts +1 -0
  43. package/dist/receipt-verifier.test.js +54 -0
  44. package/dist/receipts.js +11 -2
  45. package/dist/settlement-driver.d.ts +3 -0
  46. package/dist/settlement-driver.js +120 -0
  47. package/dist/settlement-driver.test.d.ts +1 -0
  48. package/dist/settlement-driver.test.js +78 -0
  49. package/dist/settlement-runner.d.ts +34 -0
  50. package/dist/settlement-runner.js +40 -0
  51. package/dist/settlement-runner.test.d.ts +1 -0
  52. package/dist/settlement-runner.test.js +128 -0
  53. package/dist/status.js +2 -0
  54. package/dist/store.d.ts +2 -0
  55. package/dist/store.js +33 -9
  56. package/dist/technocore-link.d.ts +34 -0
  57. package/dist/technocore-link.js +49 -0
  58. package/dist/technocore-link.test.d.ts +1 -0
  59. package/dist/technocore-link.test.js +36 -0
  60. package/dist/tool-metadata.test.d.ts +1 -0
  61. package/dist/tool-metadata.test.js +86 -0
  62. package/dist/types.d.ts +6 -0
  63. package/dist/usage-recovery.test.d.ts +1 -0
  64. package/dist/usage-recovery.test.js +121 -0
  65. package/package.json +7 -5
  66. package/server.json +2 -2
package/NOTICE ADDED
@@ -0,0 +1,8 @@
1
+ AI Proof of Us (AIPOU) MCP Server
2
+ Copyright (c) 2026 0xddneto and AI Proof of Us contributors
3
+
4
+ This package bundles the stdio runtime from the Model Context Protocol
5
+ TypeScript SDK, Copyright (c) 2024 Anthropic, PBC, under the MIT License.
6
+ Source: https://github.com/modelcontextprotocol/typescript-sdk
7
+
8
+ The software and bundled dependency are distributed under the MIT License.
package/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # AIPOU MCP Server
2
2
 
3
+ Version 0.6.0: see the [release notes](https://github.com/0xddneto/AI-Proof-of-Us/blob/main/CHANGELOG.md) and [independent pilot guide](https://github.com/0xddneto/AI-Proof-of-Us/blob/main/docs/independent-pilot.md).
4
+
5
+ `--init` uses the current absolute entrypoint from a source checkout and pins this package version when installed from npm. Windows `--doctor` marks uninspected ACLs as `not_checked`. `complete_ai_task` accepts optional `tokenUsageMeasurement` (`measured`, `unmeasured`, `unspecified`); unmeasured counts must be zero. Legacy receipts remain verifiable. Invalid verification results expose only an unverified `claimedWorkReceiptId`, with `workReceiptId: null`.
6
+
7
+ After a lost completion response, recover the persisted receipt with `export_ai_receipts` by nonce. `--feedback` prints optional local aggregate counts without sending anything. See [claim recovery and operating limits](https://github.com/0xddneto/AI-Proof-of-Us/blob/main/docs/settlement-recovery.md) before validator operation.
8
+
3
9
  `aipou-mcp-server` records private, signed receipts for authorized AI tasks. It supports local receipt collection, reward status checks, and optional claims for validated work on Base.
4
10
 
5
11
  It does not detect hidden AI use, prove task quality, replace payment rails, or require raw prompts and outputs to leave the user's machine.
@@ -26,6 +32,17 @@ The command writes a protected `agent-wallet.key` and prints an MCP
26
32
  configuration using `AIPOU_AGENT_KEY_FILE`. Re-running it reports the existing
27
33
  identity instead of overwriting the key.
28
34
 
35
+ Verify the persistent setup without writing files, contacting an RPC, moving
36
+ funds, or submitting a claim:
37
+
38
+ ```bash
39
+ npx -y aipou-mcp-server --doctor
40
+ ```
41
+
42
+ The diagnostic returns structured checks for Node.js, storage, identity,
43
+ contract addresses, and chain. It prints only the public wallet address, never
44
+ the private key.
45
+
29
46
  From a source checkout, the same check runs with:
30
47
 
31
48
  ```bash
@@ -96,6 +113,7 @@ Publication details: [docs/npm-publication.md](https://github.com/0xddneto/AI-Pr
96
113
  - [Local Receipt Mode](https://github.com/0xddneto/AI-Proof-of-Us/tree/main/examples/local-receipt-mode)
97
114
  - [Lifecycle Adapter Example](https://github.com/0xddneto/AI-Proof-of-Us/tree/main/examples/lifecycle-adapter)
98
115
  - [Evidence Boundaries](https://github.com/0xddneto/AI-Proof-of-Us/blob/main/docs/evidence-boundaries.md)
116
+ - [Flop and Technocore Interoperability](https://github.com/0xddneto/AI-Proof-of-Us/blob/main/docs/flop-technocore-interoperability.md)
99
117
  - [Claim Validation Policy](https://github.com/0xddneto/AI-Proof-of-Us/blob/main/docs/claim-validation-policy.md)
100
118
  - [Tokenomics](https://github.com/0xddneto/AI-Proof-of-Us/blob/main/docs/tokenomics.md)
101
119
  - [Security Policy](https://github.com/0xddneto/AI-Proof-of-Us/blob/main/SECURITY.md)
@@ -0,0 +1,8 @@
1
+ import { Transform, type TransformCallback } from "node:stream";
2
+ /** Bound each JSON-RPC line before the SDK parses or validates it. */
3
+ export declare class BoundedStdioInput extends Transform {
4
+ private readonly maxLineBytes;
5
+ private bytes;
6
+ constructor(maxLineBytes?: number);
7
+ _transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void;
8
+ }
@@ -0,0 +1,21 @@
1
+ import { Transform } from "node:stream";
2
+ /** Bound each JSON-RPC line before the SDK parses or validates it. */
3
+ export class BoundedStdioInput extends Transform {
4
+ maxLineBytes;
5
+ bytes = 0;
6
+ constructor(maxLineBytes = 1024 * 1024) {
7
+ super();
8
+ this.maxLineBytes = maxLineBytes;
9
+ }
10
+ _transform(chunk, _encoding, callback) {
11
+ for (const byte of chunk) {
12
+ if (byte === 10)
13
+ this.bytes = 0;
14
+ else if (++this.bytes > this.maxLineBytes) {
15
+ callback(new Error("AIPOU_INPUT_TOO_LARGE: JSON-RPC line exceeds 1 MiB"));
16
+ return;
17
+ }
18
+ }
19
+ callback(null, chunk);
20
+ }
21
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { BoundedStdioInput } from "./bounded-input.js";
4
+ test("stdio accepts a bounded fragmented line and resets its byte count at newline", async () => {
5
+ const stream = new BoundedStdioInput(6);
6
+ const chunks = [];
7
+ stream.on("data", (b) => chunks.push(b));
8
+ stream.write(Buffer.from("123"));
9
+ stream.write(Buffer.from("456\n123456\n"));
10
+ stream.end();
11
+ await new Promise((resolve, reject) => {
12
+ stream.on("end", resolve);
13
+ stream.on("error", reject);
14
+ });
15
+ assert.equal(Buffer.concat(chunks).toString(), "123456\n123456\n");
16
+ });
17
+ test("oversized fragmented JSON is rejected before the offending chunk is parsed", async () => {
18
+ const stream = new BoundedStdioInput(4);
19
+ const error = new Promise((resolve) => stream.once("error", resolve));
20
+ stream.resume();
21
+ stream.write(Buffer.from("123"));
22
+ stream.end(Buffer.from("45"));
23
+ assert.match((await error).message, /INPUT_TOO_LARGE/);
24
+ });
package/dist/claims.js CHANGED
@@ -1,18 +1,25 @@
1
1
  import { MerkleTree } from "merkletreejs";
2
- import { AbiCoder, Contract, JsonRpcProvider, Wallet, concat, getAddress, getBytes, keccak256, parseUnits } from "ethers";
3
- import { readFile } from "node:fs/promises";
2
+ import { AbiCoder, Contract, JsonRpcProvider, Transaction, Wallet, concat, getAddress, getBytes, keccak256, parseUnits, } from "ethers";
3
+ import { mkdir, readFile, rm } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { collectorFingerprint, verifyCollectorPayload } from "./collector.js";
6
6
  import { verifyTaskAuthorization } from "./identity.js";
7
- import { DAY_MS, filterEligibleReceipts, settlementPolicyFromEnv } from "./policy.js";
7
+ import { DAY_MS, filterEligibleReceipts, settlementPolicyFromEnv, } from "./policy.js";
8
8
  import { deriveTrustTier } from "./providers.js";
9
- import { markBatchSettled, settledReceiptsSince, unsettledReceipts } from "./store.js";
9
+ import { exportStoredReceipts, markBatchSettled, settledReceiptsSince, unsettledReceipts, } from "./store.js";
10
+ import { withFileLock } from "./lock.js";
11
+ import { readOptionalJson, writePrivateJson } from "./private-json.js";
12
+ import { executeSettlementPlan, } from "./settlement-runner.js";
13
+ import { createSettlementDriver } from "./settlement-driver.js";
14
+ import { canonicalJson } from "./canonical.js";
15
+ import { verifyAipouReceipt } from "./receipt-verifier.js";
10
16
  const claimsAbi = [
11
17
  "function token() view returns (address)",
12
18
  "function validator() view returns (address)",
13
19
  "function approvedRoots(bytes32 root) view returns (bool)",
20
+ "function claimedReceipts(bytes32 receiptId) view returns (bool)",
14
21
  "function publishRoot(bytes32 root)",
15
- "function claimBatch(bytes32 root, address[] accounts, uint256[] amounts, bytes32[] receiptIds, bytes32[][] proofs)"
22
+ "function claimBatch(bytes32 root, address[] accounts, uint256[] amounts, bytes32[] receiptIds, bytes32[][] proofs)",
16
23
  ];
17
24
  const tokenAbi = ["function emissionController() view returns (address)"];
18
25
  function claimsAddress() {
@@ -51,6 +58,8 @@ async function trustedCollectorFingerprints() {
51
58
  return new Set(content.fingerprints);
52
59
  }
53
60
  async function verifyReceipt(receipt, trustedCollectors) {
61
+ if (!verifyAipouReceipt(receipt).valid)
62
+ throw new Error("Invalid receipt integrity or signature; settlement stopped before submission");
54
63
  const fingerprint = collectorFingerprint(receipt.collectorPublicKey);
55
64
  if (!trustedCollectors.has(fingerprint)) {
56
65
  throw new Error(`Untrusted collector ${fingerprint} for ${receipt.receiptId}`);
@@ -70,7 +79,7 @@ async function verifyReceipt(receipt, trustedCollectors) {
70
79
  chainId: receipt.authorizationChainId,
71
80
  verifyingContract: receipt.authorizationContract,
72
81
  walletAuthorization: receipt.walletAuthorization,
73
- completedAt: receipt.recordedAt
82
+ completedAt: receipt.recordedAt,
74
83
  };
75
84
  if (!verifyTaskAuthorization(session))
76
85
  throw new Error(`Invalid wallet authorization for ${receipt.receiptId}`);
@@ -80,23 +89,61 @@ async function verifyReceipt(receipt, trustedCollectors) {
80
89
  outputTokens: receipt.outputTokens,
81
90
  durationSeconds: receipt.durationSeconds,
82
91
  outputHash: receipt.outputHash,
83
- providerEvidence: receipt.providerEvidence
92
+ tokenUsageMeasurement: receipt.tokenUsageMeasurement,
93
+ providerEvidence: receipt.providerEvidence,
84
94
  }, receipt.providerEvidence);
85
95
  if (derivedTier !== receipt.trustTier)
86
96
  throw new Error(`Invalid trust tier for ${receipt.receiptId}`);
87
97
  }
88
98
  export async function settleRewards(maxReceipts = 25) {
99
+ if (!Number.isInteger(maxReceipts) || maxReceipts < 1 || maxReceipts > 100)
100
+ throw new Error("Batch size must be 1 through 100");
101
+ const directory = path.resolve(process.env.AIPOU_DATA_DIR || ".aipou");
102
+ await mkdir(directory, { recursive: true, mode: 0o700 });
103
+ try {
104
+ return await withFileLock(path.join(directory, "settlement.lock"), () => settleRewardsLocked(maxReceipts, directory));
105
+ }
106
+ catch (error) {
107
+ const message = error instanceof Error ? error.message : "";
108
+ if (message.startsWith("SETTLEMENT_") ||
109
+ message === "There are no unsettled receipts" ||
110
+ message.startsWith("No receipts pass the settlement policy"))
111
+ throw error;
112
+ throw new Error("SETTLEMENT_FAILED: inspect local configuration and saved settlement state before retrying; private RPC and transaction details are withheld.");
113
+ }
114
+ }
115
+ async function settleRewardsLocked(maxReceipts, directory) {
116
+ const journalPath = path.join(directory, "settlement-pending.json");
117
+ const pending = await readOptionalJson(journalPath);
89
118
  const candidates = await unsettledReceipts(Number.MAX_SAFE_INTEGER);
90
- if (candidates.length === 0)
119
+ if (candidates.length === 0 && !pending)
91
120
  throw new Error("There are no unsettled receipts");
92
121
  const policy = settlementPolicyFromEnv();
93
122
  const recentlySettled = await settledReceiptsSince(Date.now() - DAY_MS);
94
123
  const { eligible, skipped } = filterEligibleReceipts(candidates, recentlySettled, policy);
95
- if (eligible.length === 0) {
96
- const reasons = skipped.map((item) => `${item.receiptId.slice(0, 10)}…: ${item.reason}`).join("; ");
124
+ if (eligible.length === 0 && !pending) {
125
+ const reasons = skipped
126
+ .map((item) => `${item.receiptId.slice(0, 10)}…: ${item.reason}`)
127
+ .join("; ");
97
128
  throw new Error(`No receipts pass the settlement policy. Skipped: ${reasons}`);
98
129
  }
99
- const receipts = eligible.slice(0, maxReceipts);
130
+ let receipts = eligible.slice(0, maxReceipts);
131
+ if (pending) {
132
+ if (pending.version !== 1 ||
133
+ pending.chainId !== 8453 ||
134
+ !Array.isArray(pending.receiptIds) ||
135
+ pending.receiptIds.length < 1 ||
136
+ pending.receiptIds.length > 100) {
137
+ throw new Error("SETTLEMENT_JOURNAL_INVALID: invalid saved batch");
138
+ }
139
+ const stored = await exportStoredReceipts();
140
+ receipts = pending.receiptIds.map((id) => {
141
+ const receipt = stored.find((item) => item.receiptId === id);
142
+ if (!receipt)
143
+ throw new Error("SETTLEMENT_JOURNAL_INVALID: saved receipt is missing");
144
+ return receipt;
145
+ });
146
+ }
100
147
  const trustedCollectors = await trustedCollectorFingerprints();
101
148
  for (const receipt of receipts)
102
149
  await verifyReceipt(receipt, trustedCollectors);
@@ -115,6 +162,8 @@ export async function settleRewards(maxReceipts = 25) {
115
162
  const proofs = leaves.map((leaf) => tree.getHexProof(leaf));
116
163
  const signer = await validatorWallet();
117
164
  const address = claimsAddress();
165
+ if ((await signer.provider.getNetwork()).chainId !== 8453n)
166
+ throw new Error("Settlement requires Base mainnet (8453)");
118
167
  const claims = new Contract(address, claimsAbi, signer);
119
168
  const configuredValidator = getAddress(await claims.validator());
120
169
  if (configuredValidator !== signer.address) {
@@ -125,28 +174,59 @@ export async function settleRewards(maxReceipts = 25) {
125
174
  if (getAddress(await token.emissionController()) !== address) {
126
175
  throw new Error("AIPOUClaims is not the token emission controller");
127
176
  }
128
- const publish = await claims.publishRoot(root);
129
- const publishReceipt = await publish.wait();
130
- let rootApproved = false;
131
- for (let attempt = 0; attempt < 10 && !rootApproved; attempt += 1) {
132
- rootApproved = await claims.approvedRoots(root);
133
- if (!rootApproved)
134
- await new Promise((resolve) => setTimeout(resolve, 3_000));
177
+ const expected = {
178
+ version: 1,
179
+ chainId: 8453,
180
+ contract: address,
181
+ root,
182
+ accounts,
183
+ amounts: amounts.map(String),
184
+ receiptIds,
185
+ proofs,
186
+ };
187
+ if (pending) {
188
+ const { publish: _publish, claim: _claim, ...saved } = pending;
189
+ if (canonicalJson(saved) !== canonicalJson(expected))
190
+ throw new Error("SETTLEMENT_JOURNAL_INVALID: batch does not match verified receipts and configured contract");
191
+ }
192
+ const plan = pending ?? expected;
193
+ for (const kind of ["publish", "claim"]) {
194
+ const saved = plan[kind];
195
+ if (!saved)
196
+ continue;
197
+ const transaction = Transaction.from(saved.raw);
198
+ const expectedData = kind === "publish"
199
+ ? claims.interface.encodeFunctionData("publishRoot", [root])
200
+ : claims.interface.encodeFunctionData("claimBatch", [
201
+ root,
202
+ accounts,
203
+ amounts,
204
+ receiptIds,
205
+ proofs,
206
+ ]);
207
+ if (transaction.hash !== saved.hash ||
208
+ transaction.from !== signer.address ||
209
+ transaction.chainId !== 8453n ||
210
+ transaction.to?.toLowerCase() !== address.toLowerCase() ||
211
+ transaction.value !== 0n ||
212
+ transaction.data !== expectedData) {
213
+ throw new Error("SETTLEMENT_JOURNAL_INVALID: saved transaction does not match the verified batch");
214
+ }
135
215
  }
136
- if (!rootApproved)
137
- throw new Error("Published Merkle root was not visible through the RPC after 30 seconds");
138
- const claim = await claims.claimBatch(root, accounts, amounts, receiptIds, proofs);
139
- const claimReceipt = await claim.wait();
140
- if (!publishReceipt || !claimReceipt)
141
- throw new Error("Settlement transaction receipt is unavailable");
216
+ const persist = (value) => writePrivateJson(journalPath, value);
217
+ await persist(plan);
218
+ const transactions = await executeSettlementPlan(plan, createSettlementDriver(signer, address), persist);
219
+ const confirmations = await Promise.all(receiptIds.map((id) => claims.claimedReceipts(id)));
220
+ if (!confirmations.every(Boolean))
221
+ throw new Error("SETTLEMENT_CLAIMS_UNCONFIRMED: receipt flags are not visible; journal retained for reconciliation");
142
222
  const batch = {
143
223
  root,
144
224
  receiptIds,
145
- publishTransaction: publishReceipt.hash,
146
- claimTransaction: claimReceipt.hash,
147
- settledAt: new Date().toISOString()
225
+ ...transactions,
226
+ settledAt: new Date().toISOString(),
148
227
  };
149
228
  await markBatchSettled(batch);
229
+ await rm(journalPath, { force: true });
150
230
  return { ...batch, skippedReceipts: skipped };
151
231
  }
152
232
  export async function settleAllRewards(batchSize = 100, maxBatches = 20) {
@@ -162,7 +242,12 @@ export async function settleAllRewards(batchSize = 100, maxBatches = 20) {
162
242
  }
163
243
  }
164
244
  catch (error) {
165
- const message = error instanceof Error ? error.message : String(error);
245
+ // Never return signed raw transactions, RPC credentials or local collector paths.
246
+ const raw = error instanceof Error ? error.message : "Settlement failed";
247
+ const message = raw.startsWith("SETTLEMENT_") ||
248
+ raw === "There are no unsettled receipts"
249
+ ? raw.slice(0, 400)
250
+ : "SETTLEMENT_FAILED: inspect local diagnostics and saved settlement state before retrying; no private transaction payload is returned.";
166
251
  if (message === "There are no unsettled receipts")
167
252
  break;
168
253
  stoppedReason = message;
@@ -172,7 +257,8 @@ export async function settleAllRewards(batchSize = 100, maxBatches = 20) {
172
257
  if (!stoppedReason && batches.length === maxBatches) {
173
258
  stoppedReason = `Stopped after maxBatches=${maxBatches}; run settle_all_ai_rewards again for any remaining receipts`;
174
259
  }
175
- const pendingReceiptCount = (await unsettledReceipts(Number.MAX_SAFE_INTEGER)).length;
260
+ const pendingReceiptCount = (await unsettledReceipts(Number.MAX_SAFE_INTEGER))
261
+ .length;
176
262
  const skippedReceipts = [...skippedByReceiptId.values()];
177
263
  return {
178
264
  batches,
@@ -180,9 +266,11 @@ export async function settleAllRewards(batchSize = 100, maxBatches = 20) {
180
266
  settledReceiptCount: batches.reduce((total, batch) => total + batch.receiptIds.length, 0),
181
267
  skippedReceipts,
182
268
  pendingReceiptCount,
183
- publishTransactions: batches.map((batch) => batch.publishTransaction),
269
+ publishTransactions: batches
270
+ .map((batch) => batch.publishTransaction)
271
+ .filter(Boolean),
184
272
  claimTransactions: batches.map((batch) => batch.claimTransaction),
185
273
  stoppedReason,
186
- settledAt: new Date().toISOString()
274
+ settledAt: new Date().toISOString(),
187
275
  };
188
276
  }
@@ -0,0 +1,32 @@
1
+ type CheckStatus = "pass" | "fail" | "warning" | "not_checked";
2
+ export interface DoctorCheck {
3
+ status: CheckStatus;
4
+ detail: string;
5
+ }
6
+ export interface InstallationDoctorResult {
7
+ mode: "installation_doctor";
8
+ ready: boolean;
9
+ version: string;
10
+ wallet: string | null;
11
+ dataDir: string;
12
+ keyFile: string | null;
13
+ keySource: "key_file" | "inline_private_key" | "missing";
14
+ checks: {
15
+ node: DoctorCheck;
16
+ dataDirectory: DoctorCheck;
17
+ identity: DoctorCheck;
18
+ keyPermissions: DoctorCheck;
19
+ tokenAddress: DoctorCheck;
20
+ claimsAddress: DoctorCheck;
21
+ chain: DoctorCheck;
22
+ };
23
+ safety: {
24
+ privateKeyPrinted: false;
25
+ filesWritten: false;
26
+ networkRequestsMade: false;
27
+ fundsMoved: false;
28
+ claimSubmitted: false;
29
+ };
30
+ }
31
+ export declare function runInstallationDoctor(dataDirInput?: string): Promise<InstallationDoctorResult>;
32
+ export {};
package/dist/doctor.js ADDED
@@ -0,0 +1,112 @@
1
+ import { constants } from "node:fs";
2
+ import { access, readFile, stat } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { Wallet, isAddress } from "ethers";
6
+ import { AIPOU_CLAIMS_ADDRESS, AIPOU_TOKEN_ADDRESS } from "./init.js";
7
+ import { getPackageVersion } from "./version.js";
8
+ function pass(detail) {
9
+ return { status: "pass", detail };
10
+ }
11
+ function fail(detail) {
12
+ return { status: "fail", detail };
13
+ }
14
+ function warning(detail) {
15
+ return { status: "warning", detail };
16
+ }
17
+ export async function runInstallationDoctor(dataDirInput) {
18
+ const dataDir = path.resolve(dataDirInput || process.env.AIPOU_DATA_DIR || path.join(os.homedir(), ".aipou"));
19
+ // An explicit directory is an operator choice and must not be shadowed by
20
+ // inherited client or dotenv identity variables.
21
+ const configuredKeyFile = dataDirInput ? undefined : process.env.AIPOU_AGENT_KEY_FILE?.trim();
22
+ const inlinePrivateKey = dataDirInput ? undefined : process.env.AIPOU_AGENT_PRIVATE_KEY?.trim();
23
+ const keyFile = inlinePrivateKey ? null : path.resolve(configuredKeyFile || path.join(dataDir, "agent-wallet.key"));
24
+ const nodeMajor = Number(process.versions.node.split(".")[0]);
25
+ const tokenAddress = process.env.AIPOU_CONTRACT_ADDRESS?.trim() || AIPOU_TOKEN_ADDRESS;
26
+ const claimsAddress = process.env.AIPOU_CLAIMS_ADDRESS?.trim() || AIPOU_CLAIMS_ADDRESS;
27
+ const chainId = Number(process.env.AIPOU_CHAIN_ID || 8453);
28
+ let dataDirectory = pass("Receipt directory is readable and writable.");
29
+ try {
30
+ await access(dataDir, constants.R_OK | constants.W_OK);
31
+ }
32
+ catch {
33
+ dataDirectory = fail(`Receipt directory is unavailable (${dataDir}); run aipou-mcp --init first.`);
34
+ }
35
+ let wallet = null;
36
+ let identity;
37
+ let keySource;
38
+ try {
39
+ const privateKey = inlinePrivateKey || (keyFile ? (await readFile(keyFile, "utf8")).trim() : "");
40
+ if (!privateKey)
41
+ throw new Error("missing");
42
+ wallet = new Wallet(privateKey).address;
43
+ keySource = inlinePrivateKey ? "inline_private_key" : "key_file";
44
+ identity = inlinePrivateKey
45
+ ? warning("Dedicated wallet key is valid, but a protected key file is safer than an inline environment variable.")
46
+ : pass("Dedicated-wallet key file is readable and valid; its secret was not returned.");
47
+ }
48
+ catch {
49
+ keySource = "missing";
50
+ identity = fail(`Dedicated wallet key is missing or invalid${keyFile ? ` (${keyFile})` : ""}; run aipou-mcp --init.`);
51
+ }
52
+ let keyPermissions;
53
+ if (inlinePrivateKey) {
54
+ keyPermissions = warning("Inline private-key permissions cannot be checked; use the key file created by aipou-mcp --init.");
55
+ }
56
+ else if (!keyFile) {
57
+ keyPermissions = fail("No dedicated-wallet key file is configured.");
58
+ }
59
+ else {
60
+ try {
61
+ const keyStat = await stat(keyFile);
62
+ if (process.platform === "win32") {
63
+ keyPermissions = { status: "not_checked", detail: "Windows ACLs are not inspected by this read-only doctor. This is not a detected permission failure; init reports whether it successfully applied access restrictions." };
64
+ }
65
+ else if ((keyStat.mode & 0o077) === 0) {
66
+ keyPermissions = pass("Key file has no group or other permission bits.");
67
+ }
68
+ else {
69
+ keyPermissions = fail("Key file is accessible by group or other users; restrict it to mode 0600.");
70
+ }
71
+ }
72
+ catch {
73
+ keyPermissions = fail(`Key file permissions could not be inspected (${keyFile}).`);
74
+ }
75
+ }
76
+ const checks = {
77
+ node: Number.isInteger(nodeMajor) && nodeMajor >= 20
78
+ ? pass(`Node.js ${process.versions.node} satisfies the required major version (20+).`)
79
+ : fail(`Node.js ${process.versions.node} is unsupported; install Node.js 20 or newer.`),
80
+ dataDirectory,
81
+ identity,
82
+ keyPermissions,
83
+ tokenAddress: isAddress(tokenAddress)
84
+ ? pass(`AIPOU token address is valid (${tokenAddress}).`)
85
+ : fail("AIPOU_CONTRACT_ADDRESS is not a valid EVM address."),
86
+ claimsAddress: isAddress(claimsAddress)
87
+ ? pass(`AIPOU claims address is valid (${claimsAddress}).`)
88
+ : fail("AIPOU_CLAIMS_ADDRESS is not a valid EVM address."),
89
+ chain: Number.isSafeInteger(chainId) && chainId > 0
90
+ ? (chainId === 8453
91
+ ? pass("Chain ID is Base Mainnet (8453).")
92
+ : warning(`Chain ID ${chainId} is configured instead of Base Mainnet (8453).`))
93
+ : fail("AIPOU_CHAIN_ID must be a positive integer.")
94
+ };
95
+ return {
96
+ mode: "installation_doctor",
97
+ ready: Object.values(checks).every((check) => check.status !== "fail"),
98
+ version: await getPackageVersion(),
99
+ wallet,
100
+ dataDir,
101
+ keyFile,
102
+ keySource,
103
+ checks,
104
+ safety: {
105
+ privateKeyPrinted: false,
106
+ filesWritten: false,
107
+ networkRequestsMade: false,
108
+ fundsMoved: false,
109
+ claimSubmitted: false
110
+ }
111
+ };
112
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,164 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFile } from "node:child_process";
3
+ import { access, mkdtemp, readFile, rm } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+ import { promisify } from "node:util";
9
+ import { Wallet } from "ethers";
10
+ import { runInstallationDoctor } from "./doctor.js";
11
+ import { initializePersistentIdentity } from "./init.js";
12
+ const execFileAsync = promisify(execFile);
13
+ test("installation doctor validates init output without exposing or changing the key", async () => {
14
+ const dataDir = await mkdtemp(path.join(os.tmpdir(), "aipou-doctor-test-"));
15
+ const previousInlineKey = process.env.AIPOU_AGENT_PRIVATE_KEY;
16
+ const previousKeyFile = process.env.AIPOU_AGENT_KEY_FILE;
17
+ try {
18
+ delete process.env.AIPOU_AGENT_PRIVATE_KEY;
19
+ delete process.env.AIPOU_AGENT_KEY_FILE;
20
+ const initialized = await initializePersistentIdentity(dataDir);
21
+ const privateKeyBefore = await readFile(initialized.keyFile, "utf8");
22
+ const result = await runInstallationDoctor(dataDir);
23
+ const privateKeyAfter = await readFile(initialized.keyFile, "utf8");
24
+ assert.equal(result.ready, true);
25
+ assert.equal(result.wallet, initialized.wallet);
26
+ assert.equal(result.keySource, "key_file");
27
+ if (process.platform === "win32")
28
+ assert.equal(result.checks.keyPermissions.status, "not_checked");
29
+ assert.equal(result.version.length > 0, true);
30
+ assert.equal(Object.values(result.checks).every((check) => check.status !== "fail"), true);
31
+ assert.equal(privateKeyAfter, privateKeyBefore);
32
+ assert.equal(JSON.stringify(result).includes(privateKeyBefore.trim()), false);
33
+ assert.deepEqual(result.safety, {
34
+ privateKeyPrinted: false,
35
+ filesWritten: false,
36
+ networkRequestsMade: false,
37
+ fundsMoved: false,
38
+ claimSubmitted: false
39
+ });
40
+ }
41
+ finally {
42
+ if (previousInlineKey === undefined)
43
+ delete process.env.AIPOU_AGENT_PRIVATE_KEY;
44
+ else
45
+ process.env.AIPOU_AGENT_PRIVATE_KEY = previousInlineKey;
46
+ if (previousKeyFile === undefined)
47
+ delete process.env.AIPOU_AGENT_KEY_FILE;
48
+ else
49
+ process.env.AIPOU_AGENT_KEY_FILE = previousKeyFile;
50
+ await rm(dataDir, { recursive: true, force: true });
51
+ }
52
+ });
53
+ test("installation doctor fails closed without creating a missing directory", async () => {
54
+ const parent = await mkdtemp(path.join(os.tmpdir(), "aipou-doctor-missing-test-"));
55
+ const dataDir = path.join(parent, "missing");
56
+ const previousInlineKey = process.env.AIPOU_AGENT_PRIVATE_KEY;
57
+ const previousKeyFile = process.env.AIPOU_AGENT_KEY_FILE;
58
+ try {
59
+ delete process.env.AIPOU_AGENT_PRIVATE_KEY;
60
+ delete process.env.AIPOU_AGENT_KEY_FILE;
61
+ const result = await runInstallationDoctor(dataDir);
62
+ assert.equal(result.ready, false);
63
+ assert.equal(result.wallet, null);
64
+ assert.equal(result.keySource, "missing");
65
+ assert.equal(result.checks.dataDirectory.status, "fail");
66
+ assert.equal(result.checks.identity.status, "fail");
67
+ await assert.rejects(access(dataDir));
68
+ }
69
+ finally {
70
+ if (previousInlineKey === undefined)
71
+ delete process.env.AIPOU_AGENT_PRIVATE_KEY;
72
+ else
73
+ process.env.AIPOU_AGENT_PRIVATE_KEY = previousInlineKey;
74
+ if (previousKeyFile === undefined)
75
+ delete process.env.AIPOU_AGENT_KEY_FILE;
76
+ else
77
+ process.env.AIPOU_AGENT_KEY_FILE = previousKeyFile;
78
+ await rm(parent, { recursive: true, force: true });
79
+ }
80
+ });
81
+ test("installation doctor supports the legacy inline key without exposing it", async () => {
82
+ const dataDir = await mkdtemp(path.join(os.tmpdir(), "aipou-doctor-inline-test-"));
83
+ const previousInlineKey = process.env.AIPOU_AGENT_PRIVATE_KEY;
84
+ const previousKeyFile = process.env.AIPOU_AGENT_KEY_FILE;
85
+ const previousDataDir = process.env.AIPOU_DATA_DIR;
86
+ const wallet = Wallet.createRandom();
87
+ try {
88
+ process.env.AIPOU_AGENT_PRIVATE_KEY = wallet.privateKey;
89
+ process.env.AIPOU_DATA_DIR = dataDir;
90
+ delete process.env.AIPOU_AGENT_KEY_FILE;
91
+ const result = await runInstallationDoctor();
92
+ assert.equal(result.ready, true);
93
+ assert.equal(result.wallet, wallet.address);
94
+ assert.equal(result.keySource, "inline_private_key");
95
+ assert.equal(result.keyFile, null);
96
+ assert.equal(result.checks.identity.status, "warning");
97
+ assert.equal(result.checks.keyPermissions.status, "warning");
98
+ assert.equal(JSON.stringify(result).includes(wallet.privateKey), false);
99
+ }
100
+ finally {
101
+ if (previousInlineKey === undefined)
102
+ delete process.env.AIPOU_AGENT_PRIVATE_KEY;
103
+ else
104
+ process.env.AIPOU_AGENT_PRIVATE_KEY = previousInlineKey;
105
+ if (previousKeyFile === undefined)
106
+ delete process.env.AIPOU_AGENT_KEY_FILE;
107
+ else
108
+ process.env.AIPOU_AGENT_KEY_FILE = previousKeyFile;
109
+ if (previousDataDir === undefined)
110
+ delete process.env.AIPOU_DATA_DIR;
111
+ else
112
+ process.env.AIPOU_DATA_DIR = previousDataDir;
113
+ await rm(dataDir, { recursive: true, force: true });
114
+ }
115
+ });
116
+ test("installation doctor rejects invalid addresses and chain IDs without throwing", async () => {
117
+ const dataDir = await mkdtemp(path.join(os.tmpdir(), "aipou-doctor-config-test-"));
118
+ const previousContract = process.env.AIPOU_CONTRACT_ADDRESS;
119
+ const previousChainId = process.env.AIPOU_CHAIN_ID;
120
+ try {
121
+ await initializePersistentIdentity(dataDir);
122
+ process.env.AIPOU_CONTRACT_ADDRESS = "not-an-address";
123
+ process.env.AIPOU_CHAIN_ID = "not-a-chain";
124
+ const result = await runInstallationDoctor(dataDir);
125
+ assert.equal(result.ready, false);
126
+ assert.equal(result.checks.tokenAddress.status, "fail");
127
+ assert.equal(result.checks.chain.status, "fail");
128
+ }
129
+ finally {
130
+ if (previousContract === undefined)
131
+ delete process.env.AIPOU_CONTRACT_ADDRESS;
132
+ else
133
+ process.env.AIPOU_CONTRACT_ADDRESS = previousContract;
134
+ if (previousChainId === undefined)
135
+ delete process.env.AIPOU_CHAIN_ID;
136
+ else
137
+ process.env.AIPOU_CHAIN_ID = previousChainId;
138
+ await rm(dataDir, { recursive: true, force: true });
139
+ }
140
+ });
141
+ test("CLI doctor reports a ready persistent identity without printing its secret", async () => {
142
+ const dataDir = await mkdtemp(path.join(os.tmpdir(), "aipou-doctor-cli-test-"));
143
+ const indexPath = fileURLToPath(new URL("./index.js", import.meta.url));
144
+ try {
145
+ const initialized = await initializePersistentIdentity(dataDir);
146
+ const privateKey = (await readFile(initialized.keyFile, "utf8")).trim();
147
+ const inheritedWallet = Wallet.createRandom();
148
+ const { stdout, stderr } = await execFileAsync(process.execPath, [indexPath, "--doctor", "--data-dir", dataDir], {
149
+ env: {
150
+ ...process.env,
151
+ AIPOU_AGENT_PRIVATE_KEY: inheritedWallet.privateKey,
152
+ AIPOU_AGENT_KEY_FILE: path.join(dataDir, "wrong-key-file")
153
+ }
154
+ });
155
+ const result = JSON.parse(stdout);
156
+ assert.equal(result.ready, true);
157
+ assert.equal(result.wallet, initialized.wallet);
158
+ assert.equal(stdout.includes(privateKey), false);
159
+ assert.equal(stderr, "");
160
+ }
161
+ finally {
162
+ await rm(dataDir, { recursive: true, force: true });
163
+ }
164
+ });