@projectsolo/solo-mission-mcp 0.21.9 → 0.21.11

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/dist/index.js CHANGED
@@ -19,25 +19,38 @@ import {
19
19
  var RESPONSE_SCHEMA_PROPERTY = {
20
20
  type: "array",
21
21
  maxItems: 20,
22
- description: "Structured completion questions a human answers to complete the mission. When set, finalize_qualification auto-derives qualified UIDs from completion instead of requiring an explicit qualified_human_uids list \u2014 same mechanism media_review has always used for track ratings, generalized to any mission type. media_review defaults to a canonical {rating (stars), comment (long, optional)} schema when this is omitted at creation; every other type has no default (stays fully manual/chat-based unless you set this).",
22
+ description: 'Structured completion questions a human answers to complete the mission. Question ids must be unique within one schema \u2014 a duplicate id is rejected at creation/update, not merged or silently overwritten. When set, finalize_qualification auto-derives qualified UIDs from completion instead of requiring an explicit qualified_human_uids list \u2014 same mechanism media_review has always used for track ratings, generalized to any mission type. media_review defaults to a canonical two-question schema when this is omitted at creation \u2014 literally { id: "rating", kind: "stars", required: true } and { id: "comment", kind: "long", required: false } \u2014 so read/append against those exact ids if you rely on the default rather than supplying your own schema. Every other type has no default (stays fully manual/chat-based unless you set this).',
23
23
  items: {
24
24
  type: "object",
25
25
  properties: {
26
- id: { type: "string", description: "Unique id within this schema \u2014 the key answers are stored under." },
26
+ id: { type: "string", description: "Unique id within this schema \u2014 the key answers are stored under. Duplicates are rejected, not merged." },
27
27
  kind: {
28
28
  type: "string",
29
- enum: ["single", "multi", "likert", "stars", "short", "long", "dropdown", "consent"],
30
- description: "single=one choice, multi=several choices, likert=5-point agreement scale, stars=1-5 rating, short=one line of text, long=paragraph text (see min_length), dropdown=one choice from a long list, consent=a checkbox that must be explicitly true."
29
+ enum: [
30
+ "single",
31
+ "multi",
32
+ "likert",
33
+ "stars",
34
+ "short",
35
+ "long",
36
+ "dropdown",
37
+ "checkbox",
38
+ "timestamp_tag",
39
+ "image_region",
40
+ "video_region_duration"
41
+ ],
42
+ description: 'single=one choice, multi=several choices, likert=a scale rendered as a row of buttons (NOT fixed at 5 points \u2014 the number of options you supply IS the number of points, so give exactly as many option labels as the scale should have; a common convention is 5 labels like "1 Strongly disagree".."5 Strongly agree" but nothing enforces that), stars=1-5 rating (fixed, no options), short=one line of text, long=paragraph text (see min_length), dropdown=one choice from a long list, checkbox=a checkbox that must be explicitly true (usable for ANY yes/no question, not just consent-flavored ones) \u2014 the checkbox\'s own clickable text is `label` itself (there is no separate heading rendered above it, unlike every other kind); use `help` for any additional wording you want shown near it.\n\nMedia-anchored kinds \u2014 media_review only, need a track to annotate (options/min_length don\'t apply; see min_count instead):\n timestamp_tag: the human flags a moment in an audio/video track while it plays. Each flag is { t (seconds), tag (category \u2014 one of `options` if you set it, otherwise any non-empty string), note? }. `options` is OPTIONAL here (unlike the choice kinds above) \u2014 omit it for free-form tagging, or set it to offer a closed category list.\n image_region: the human draws a rectangle on an image track and comments on it. Each region is { x, y, width, height (all 0-1, fraction of the image, not pixels), comment }.\n video_region_duration: like image_region, but the rectangle is held for a single time range rather than a single point \u2014 { start, end (seconds, end > start), x, y, width, height, comment }. One fixed box for the whole range, not tracked frame by frame.'
31
43
  },
32
- label: { type: "string", description: "Question text shown to the human (max 200 chars)." },
44
+ label: { type: "string", description: "Question text shown to the human (max 200 chars). For the checkbox kind this is the checkbox's own clickable text, not a separate heading." },
33
45
  help: { type: "string", description: "Optional helper text shown under the label (max 500 chars)." },
34
46
  required: { type: "boolean", description: "Whether this question must be answered to complete the mission." },
35
47
  options: {
36
48
  type: "array",
37
49
  items: { type: "string" },
38
- description: "Required for single/multi/dropdown/likert (max 20 options, 50 chars each). Not accepted by other kinds."
50
+ description: "Required (non-empty) for single/multi/dropdown/likert (max 20 options, 50 chars each). Optional for timestamp_tag (a closed category list; omit for free-form tagging). Not accepted by any other kind."
39
51
  },
40
- min_length: { type: "number", description: "Minimum answer length, only meaningful for the 'long' kind." }
52
+ min_length: { type: "number", description: "Minimum answer length. Only read for the 'long' kind \u2014 silently ignored (not rejected) if set on any other kind." },
53
+ min_count: { type: "number", description: "Minimum number of annotations required. Only read for timestamp_tag/image_region/video_region_duration (defaults to 1 if required and unset) \u2014 silently ignored on any other kind." }
41
54
  },
42
55
  required: ["id", "kind", "label", "required"]
43
56
  }
@@ -90,7 +103,7 @@ var missionTools = [
90
103
  },
91
104
  {
92
105
  name: "update_mission_questions",
93
- description: "Set or replace a mission's response_schema after creation. Locked once hiring starts \u2014 same window as add_mission_track (on-chain: only before funding; off-chain: only while active with zero hired participants yet). Replaces the entire schema, not a merge.",
106
+ description: "Set or replace a mission's response_schema after creation. Locked once hiring starts \u2014 same window as add_mission_track (on-chain: only before funding; off-chain: only while active with zero hired participants yet). This window is what makes a schema replacement safe: a human can only submit answers once hired, and the schema can no longer change once anyone has been hired \u2014 there is no state where a human has answered under one schema and the mission later presents a different one. Replaces the entire schema, not a merge.",
94
107
  inputSchema: {
95
108
  type: "object",
96
109
  properties: {
@@ -1046,7 +1059,7 @@ async function handleSolanaTool(name, args) {
1046
1059
  case "get_solana_config":
1047
1060
  return apiGet2("/agent/solana/config");
1048
1061
  case "get_solana_wallet": {
1049
- const { hasSolanaWallet, loadSolanaWallet, associatedTokenAddress } = await import("./wallet-IUQWBW6F.js");
1062
+ const { hasSolanaWallet, loadSolanaWallet, associatedTokenAddress } = await import("./wallet-V4T4NTCM.js");
1050
1063
  if (!hasSolanaWallet()) {
1051
1064
  return {
1052
1065
  configured: false,
@@ -1092,7 +1105,7 @@ async function handleSolanaTool(name, args) {
1092
1105
  };
1093
1106
  }
1094
1107
  case "fund_solana_mission": {
1095
- const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-IUQWBW6F.js");
1108
+ const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-V4T4NTCM.js");
1096
1109
  const { verifyFundingTransaction } = await import("./verify-KAETIGV5.js");
1097
1110
  const wallet = await loadSolanaWallet();
1098
1111
  const cfg = await apiGet2("/agent/solana/config");
@@ -1152,10 +1165,17 @@ async function handleSolanaTool(name, args) {
1152
1165
  return { funded: true, verified: true, ...confirmed };
1153
1166
  }
1154
1167
  case "refund_solana_mission": {
1155
- const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-IUQWBW6F.js");
1168
+ const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-V4T4NTCM.js");
1156
1169
  const wallet = await loadSolanaWallet();
1157
1170
  const cfg = await apiGet2("/agent/solana/config");
1158
- const mint = cfg.mints.TEST_USDC ?? Object.values(cfg.mints)[0];
1171
+ const mission = await apiGet2(`/agent/missions/${args.mission_id}`);
1172
+ const mint = mission.mission?.token_address;
1173
+ if (!mint) {
1174
+ return {
1175
+ refunded: false,
1176
+ error: `Could not resolve mission ${args.mission_id}'s token_address \u2014 call get_mission to check it exists and is funded.`
1177
+ };
1178
+ }
1159
1179
  const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
1160
1180
  if (!args.action) {
1161
1181
  try {
@@ -0,0 +1,143 @@
1
+ // src/solana/wallet.ts
2
+ import { readFileSync } from "fs";
3
+ import { createDecipheriv, pbkdf2Sync } from "crypto";
4
+ var SolanaWalletUnavailable = class extends Error {
5
+ constructor(reason) {
6
+ super(
7
+ `Solana wallet unavailable: ${reason}
8
+
9
+ Set one of:
10
+ SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH + SOLO_SOLANA_KEYPAIR_PASSWORD_FILE
11
+ - recommended: an openssl-aes-256-cbc-pbkdf2-encrypted
12
+ keyfile plus a separate passphrase file (chmod 600 both)
13
+ SOLO_SOLANA_KEYPAIR - JSON byte array, as \`solana-keygen\` writes it (plaintext)
14
+ SOLO_SOLANA_KEYPAIR_PATH - path to that file (e.g. ~/.config/solana/id.json), plaintext
15
+
16
+ The wallet needs SOL for rent and fees, and USDC for the mission budget. Rent is a
17
+ refundable deposit, not a fee: most of it returns when the task is closed.`
18
+ );
19
+ this.name = "SolanaWalletUnavailable";
20
+ }
21
+ };
22
+ function decryptOpenSslAes256CbcPbkdf2(encrypted, password) {
23
+ const MAGIC = "Salted__";
24
+ if (encrypted.length < 16 || encrypted.subarray(0, 8).toString("utf8") !== MAGIC) {
25
+ throw new SolanaWalletUnavailable('encrypted keyfile is missing the OpenSSL "Salted__" header');
26
+ }
27
+ const salt = encrypted.subarray(8, 16);
28
+ const ciphertext = encrypted.subarray(16);
29
+ const derived = pbkdf2Sync(password, salt, 1e4, 48, "sha256");
30
+ const key = derived.subarray(0, 32);
31
+ const iv = derived.subarray(32, 48);
32
+ const decipher = createDecipheriv("aes-256-cbc", key, iv);
33
+ try {
34
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
35
+ } catch (e) {
36
+ throw new SolanaWalletUnavailable(
37
+ `failed to decrypt keyfile \u2014 wrong passphrase, or the file wasn't produced by 'openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256' (${e.message})`
38
+ );
39
+ }
40
+ }
41
+ function parseKeypairBytes(raw) {
42
+ const trimmed = raw.trim();
43
+ if (!trimmed.startsWith("[")) {
44
+ throw new SolanaWalletUnavailable(
45
+ "value is not a JSON byte array \u2014 this is the format `solana-keygen new` writes"
46
+ );
47
+ }
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(trimmed);
51
+ } catch {
52
+ throw new SolanaWalletUnavailable("value looks like a JSON array but does not parse");
53
+ }
54
+ if (!Array.isArray(parsed) || !parsed.every((n) => typeof n === "number")) {
55
+ throw new SolanaWalletUnavailable("JSON array must contain only numbers");
56
+ }
57
+ const bytes = Uint8Array.from(parsed);
58
+ if (bytes.length !== 64) {
59
+ throw new SolanaWalletUnavailable(
60
+ `expected 64 bytes, got ${bytes.length}` + (bytes.length === 32 ? " \u2014 this is the seed alone, not the full keypair" : "")
61
+ );
62
+ }
63
+ return bytes;
64
+ }
65
+ function expandHome(path) {
66
+ return path.replace(/^~/, process.env.HOME ?? "~");
67
+ }
68
+ var cachedWallet;
69
+ async function loadSolanaWallet() {
70
+ if (cachedWallet) return cachedWallet;
71
+ const encryptedPath = process.env.SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH;
72
+ const passwordFile = process.env.SOLO_SOLANA_KEYPAIR_PASSWORD_FILE;
73
+ const inline = process.env.SOLO_SOLANA_KEYPAIR;
74
+ const path = process.env.SOLO_SOLANA_KEYPAIR_PATH;
75
+ let raw;
76
+ if (encryptedPath && encryptedPath.trim() !== "" || passwordFile && passwordFile.trim() !== "") {
77
+ if (!encryptedPath || !passwordFile) {
78
+ throw new SolanaWalletUnavailable(
79
+ "SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH and SOLO_SOLANA_KEYPAIR_PASSWORD_FILE must both be set \u2014 only one was found"
80
+ );
81
+ }
82
+ let encrypted;
83
+ let password;
84
+ try {
85
+ encrypted = readFileSync(expandHome(encryptedPath));
86
+ } catch (e) {
87
+ throw new SolanaWalletUnavailable(`cannot read ${encryptedPath}: ${e.message}`);
88
+ }
89
+ try {
90
+ password = readFileSync(expandHome(passwordFile), "utf8").replace(/\r?\n$/, "");
91
+ } catch (e) {
92
+ throw new SolanaWalletUnavailable(`cannot read ${passwordFile}: ${e.message}`);
93
+ }
94
+ raw = decryptOpenSslAes256CbcPbkdf2(encrypted, password).toString("utf8");
95
+ } else if (inline && inline.trim() !== "") {
96
+ raw = inline;
97
+ } else if (path && path.trim() !== "") {
98
+ try {
99
+ raw = readFileSync(expandHome(path), "utf8");
100
+ } catch (e) {
101
+ throw new SolanaWalletUnavailable(`cannot read ${path}: ${e.message}`);
102
+ }
103
+ } else {
104
+ throw new SolanaWalletUnavailable(
105
+ "no wallet source configured (checked SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH, SOLO_SOLANA_KEYPAIR, SOLO_SOLANA_KEYPAIR_PATH)"
106
+ );
107
+ }
108
+ const secretKey = parseKeypairBytes(raw);
109
+ const { Keypair } = await import("@solana/web3.js");
110
+ const kp = Keypair.fromSecretKey(secretKey);
111
+ cachedWallet = { publicKey: kp.publicKey.toBase58(), secretKey };
112
+ return cachedWallet;
113
+ }
114
+ function hasSolanaWallet() {
115
+ return Boolean(
116
+ cachedWallet || (process.env.SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH ?? "").trim() && (process.env.SOLO_SOLANA_KEYPAIR_PASSWORD_FILE ?? "").trim() || (process.env.SOLO_SOLANA_KEYPAIR ?? "").trim() || (process.env.SOLO_SOLANA_KEYPAIR_PATH ?? "").trim()
117
+ );
118
+ }
119
+ async function associatedTokenAddress(mint, owner) {
120
+ const { PublicKey } = await import("@solana/web3.js");
121
+ const TOKEN_PROGRAM_ID = new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
122
+ const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL");
123
+ const [address] = PublicKey.findProgramAddressSync(
124
+ [new PublicKey(owner).toBuffer(), TOKEN_PROGRAM_ID.toBuffer(), new PublicKey(mint).toBuffer()],
125
+ ASSOCIATED_TOKEN_PROGRAM_ID
126
+ );
127
+ return address.toBase58();
128
+ }
129
+ async function signTransaction(transactionBase64, wallet) {
130
+ const { Keypair, Transaction } = await import("@solana/web3.js");
131
+ const kp = Keypair.fromSecretKey(wallet.secretKey);
132
+ const tx = Transaction.from(Buffer.from(transactionBase64, "base64"));
133
+ tx.partialSign(kp);
134
+ return tx.serialize().toString("base64");
135
+ }
136
+ export {
137
+ SolanaWalletUnavailable,
138
+ associatedTokenAddress,
139
+ decryptOpenSslAes256CbcPbkdf2,
140
+ hasSolanaWallet,
141
+ loadSolanaWallet,
142
+ signTransaction
143
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@projectsolo/solo-mission-mcp",
3
- "version": "0.21.9",
3
+ "version": "0.21.11",
4
4
  "description": "MCP server for Solo Mission Platform — lets AI agents create missions, browse humans, and chat.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,2 @@
1
+ Salted__\��H<�WCxUZ ��y`�7�A�Ǔ�K�=6��u5D$�Q���Yd�6D����a��'SHԛE����YB��h�`���/����=5�>�������,�S8�l��?[=�-H5�i^�^��Oa��R�W,��lj�Cz4/�$���bx�M�_�%;!�� A����Cұ
2
+ 0y�;�(V
@@ -0,0 +1 @@
1
+ GXkK4gbmGPGFmB7vobHeY82ktbAw9VPnHZJits1P2Qjw
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `decryptOpenSslAes256CbcPbkdf2` reimplements OpenSSL's KDF natively rather than shelling out, so
3
+ * it is tested against a REAL `openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256` fixture —
4
+ * same reasoning as `verify.test.ts`: a hand-rolled ciphertext would encode my assumptions about the
5
+ * format twice and could only ever agree with itself, not with the `openssl enc` command the docs
6
+ * actually tell operators to run.
7
+ *
8
+ * `fixtures/test-keypair.json.enc` is a throwaway, never-funded devnet keypair — safe to check in.
9
+ */
10
+ import { describe, it, expect } from 'vitest';
11
+ import { readFileSync } from 'fs';
12
+ import { join } from 'path';
13
+ import { decryptOpenSslAes256CbcPbkdf2, SolanaWalletUnavailable } from './wallet';
14
+
15
+ const FIXTURE_DIR = join(__dirname, 'fixtures');
16
+ const encrypted = readFileSync(join(FIXTURE_DIR, 'test-keypair.json.enc'));
17
+ const expectedPubkey = readFileSync(join(FIXTURE_DIR, 'test-keypair.pubkey'), 'utf8').trim();
18
+ const PASSPHRASE = 'test-only-fixture-passphrase';
19
+
20
+ describe('decryptOpenSslAes256CbcPbkdf2', () => {
21
+ it('decrypts a real openssl-produced ciphertext back to the original JSON keypair', async () => {
22
+ const plaintext = decryptOpenSslAes256CbcPbkdf2(encrypted, PASSPHRASE).toString('utf8');
23
+ const bytes = JSON.parse(plaintext);
24
+ expect(Array.isArray(bytes)).toBe(true);
25
+ expect(bytes).toHaveLength(64);
26
+
27
+ // Confirm it's not just 64 numbers — it's the actual keypair, by deriving the same public key
28
+ // `solana-keygen pubkey` reported when the fixture was generated.
29
+ const { Keypair } = await import('@solana/web3.js');
30
+ const kp = Keypair.fromSecretKey(Uint8Array.from(bytes));
31
+ expect(kp.publicKey.toBase58()).toBe(expectedPubkey);
32
+ });
33
+
34
+ it('rejects a wrong passphrase rather than silently returning garbage', () => {
35
+ expect(() => decryptOpenSslAes256CbcPbkdf2(encrypted, 'definitely-not-it')).toThrow(
36
+ SolanaWalletUnavailable,
37
+ );
38
+ });
39
+
40
+ it('rejects a file missing the OpenSSL "Salted__" header', () => {
41
+ expect(() => decryptOpenSslAes256CbcPbkdf2(Buffer.from('not an openssl file'), PASSPHRASE)).toThrow(
42
+ /Salted__/,
43
+ );
44
+ });
45
+ });
@@ -19,9 +19,28 @@
19
19
  * the Solo API — the backend builds transactions and submits them, but only the agent can authorise
20
20
  * one. That is the whole point of the partial-signing flow: a compromised backend cannot move a
21
21
  * sponsor's funds, because it never holds the signing key.
22
+ *
23
+ * AT REST, prefer SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH + SOLO_SOLANA_KEYPAIR_PASSWORD_FILE over the
24
+ * plain SOLO_SOLANA_KEYPAIR / SOLO_SOLANA_KEYPAIR_PATH pair. `solana-keygen new`'s own output is a
25
+ * plaintext JSON byte array — chmod 600 protects it from other users on the box, but not from
26
+ * whoever gets that one file (a compromised account, a stray backup, a copied disk image). The
27
+ * encrypted form splits the secret in two: the ciphertext and the passphrase file are only useful
28
+ * together, matching what `references/wallet-setup.md` already does for the Base private key
29
+ * (openssl-encrypt at rest, decrypt only transiently). Encrypt with the exact command below so the
30
+ * decryptor here (which reimplements OpenSSL's KDF, not shells out to it) derives the same key:
31
+ *
32
+ * openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256 \
33
+ * -in ~/.config/solana/solo-sponsor.json -out solo-sponsor.json.enc
34
+ * chmod 600 solo-sponsor.json.enc
35
+ * echo -n "your-passphrase" > keystore.pass && chmod 600 keystore.pass
36
+ * # then delete the plaintext solo-sponsor.json
37
+ *
38
+ * The plain SOLO_SOLANA_KEYPAIR / SOLO_SOLANA_KEYPAIR_PATH pair still works, unchanged, for
39
+ * quick-start / devnet use where this doesn't matter.
22
40
  */
23
41
 
24
42
  import { readFileSync } from 'fs';
43
+ import { createDecipheriv, pbkdf2Sync } from 'crypto';
25
44
 
26
45
  export interface SolanaWallet {
27
46
  publicKey: string;
@@ -34,8 +53,11 @@ export class SolanaWalletUnavailable extends Error {
34
53
  super(
35
54
  `Solana wallet unavailable: ${reason}\n\n` +
36
55
  'Set one of:\n' +
37
- ' SOLO_SOLANA_KEYPAIR - JSON byte array, as `solana-keygen` writes it\n' +
38
- ' SOLO_SOLANA_KEYPAIR_PATH - path to that file (e.g. ~/.config/solana/id.json)\n\n' +
56
+ ' SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH + SOLO_SOLANA_KEYPAIR_PASSWORD_FILE\n' +
57
+ ' - recommended: an openssl-aes-256-cbc-pbkdf2-encrypted\n' +
58
+ ' keyfile plus a separate passphrase file (chmod 600 both)\n' +
59
+ ' SOLO_SOLANA_KEYPAIR - JSON byte array, as `solana-keygen` writes it (plaintext)\n' +
60
+ ' SOLO_SOLANA_KEYPAIR_PATH - path to that file (e.g. ~/.config/solana/id.json), plaintext\n\n' +
39
61
  'The wallet needs SOL for rent and fees, and USDC for the mission budget. Rent is a\n' +
40
62
  'refundable deposit, not a fee: most of it returns when the task is closed.',
41
63
  );
@@ -43,6 +65,39 @@ export class SolanaWalletUnavailable extends Error {
43
65
  }
44
66
  }
45
67
 
68
+ /**
69
+ * Decrypts a file produced by `openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256`.
70
+ *
71
+ * Reimplemented natively rather than shelling out to `openssl` — this codebase has no
72
+ * child_process/exec anywhere, and a decrypt helper is not where that invariant should end. OpenSSL
73
+ * prefixes salted output with the 8-byte literal "Salted__" followed by an 8-byte salt; with
74
+ * `-pbkdf2` it derives key||iv (32 + 16 bytes for aes-256-cbc) from the passphrase and that salt in
75
+ * one PBKDF2-HMAC-SHA256 call. Verified byte-for-byte against real `openssl enc`/`openssl enc -d`
76
+ * output in `wallet.test.ts`.
77
+ */
78
+ export function decryptOpenSslAes256CbcPbkdf2(encrypted: Buffer, password: string): Buffer {
79
+ const MAGIC = 'Salted__';
80
+ if (encrypted.length < 16 || encrypted.subarray(0, 8).toString('utf8') !== MAGIC) {
81
+ throw new SolanaWalletUnavailable('encrypted keyfile is missing the OpenSSL "Salted__" header');
82
+ }
83
+ const salt = encrypted.subarray(8, 16);
84
+ const ciphertext = encrypted.subarray(16);
85
+ const derived = pbkdf2Sync(password, salt, 10000, 48, 'sha256');
86
+ const key = derived.subarray(0, 32);
87
+ const iv = derived.subarray(32, 48);
88
+ const decipher = createDecipheriv('aes-256-cbc', key, iv);
89
+ try {
90
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
91
+ } catch (e) {
92
+ // Wrong passphrase almost always surfaces here (bad padding), not as a clean "auth failed" —
93
+ // CBC has no built-in integrity check, unlike GCM.
94
+ throw new SolanaWalletUnavailable(
95
+ `failed to decrypt keyfile — wrong passphrase, or the file wasn't produced by ` +
96
+ `'openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256' (${(e as Error).message})`,
97
+ );
98
+ }
99
+ }
100
+
46
101
  function parseKeypairBytes(raw: string): Uint8Array {
47
102
  const trimmed = raw.trim();
48
103
  if (!trimmed.startsWith('[')) {
@@ -71,6 +126,16 @@ function parseKeypairBytes(raw: string): Uint8Array {
71
126
  return bytes;
72
127
  }
73
128
 
129
+ function expandHome(path: string): string {
130
+ return path.replace(/^~/, process.env.HOME ?? '~');
131
+ }
132
+
133
+ // Cached after first successful load — avoids re-deriving the PBKDF2 key (and re-reading the
134
+ // passphrase file) on every tool call. Never cache a failure: a missing/misconfigured wallet on
135
+ // one call should not wrongly still look missing after the operator fixes it mid-session, and
136
+ // vice versa a transient read error shouldn't get baked in as permanent.
137
+ let cachedWallet: SolanaWallet | undefined;
138
+
74
139
  /**
75
140
  * Loads the agent's wallet.
76
141
  *
@@ -78,32 +143,61 @@ function parseKeypairBytes(raw: string): Uint8Array {
78
143
  * with no wallet configured at all — an agent running Base missions should not need one.
79
144
  */
80
145
  export async function loadSolanaWallet(): Promise<SolanaWallet> {
146
+ if (cachedWallet) return cachedWallet;
147
+
148
+ const encryptedPath = process.env.SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH;
149
+ const passwordFile = process.env.SOLO_SOLANA_KEYPAIR_PASSWORD_FILE;
81
150
  const inline = process.env.SOLO_SOLANA_KEYPAIR;
82
151
  const path = process.env.SOLO_SOLANA_KEYPAIR_PATH;
83
152
 
84
153
  let raw: string;
85
- if (inline && inline.trim() !== '') {
154
+ if ((encryptedPath && encryptedPath.trim() !== '') || (passwordFile && passwordFile.trim() !== '')) {
155
+ if (!encryptedPath || !passwordFile) {
156
+ throw new SolanaWalletUnavailable(
157
+ 'SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH and SOLO_SOLANA_KEYPAIR_PASSWORD_FILE must both be set — only one was found',
158
+ );
159
+ }
160
+ let encrypted: Buffer;
161
+ let password: string;
162
+ try {
163
+ encrypted = readFileSync(expandHome(encryptedPath));
164
+ } catch (e) {
165
+ throw new SolanaWalletUnavailable(`cannot read ${encryptedPath}: ${(e as Error).message}`);
166
+ }
167
+ try {
168
+ password = readFileSync(expandHome(passwordFile), 'utf8').replace(/\r?\n$/, '');
169
+ } catch (e) {
170
+ throw new SolanaWalletUnavailable(`cannot read ${passwordFile}: ${(e as Error).message}`);
171
+ }
172
+ raw = decryptOpenSslAes256CbcPbkdf2(encrypted, password).toString('utf8');
173
+ } else if (inline && inline.trim() !== '') {
86
174
  raw = inline;
87
175
  } else if (path && path.trim() !== '') {
88
176
  try {
89
- raw = readFileSync(path.replace(/^~/, process.env.HOME ?? '~'), 'utf8');
177
+ raw = readFileSync(expandHome(path), 'utf8');
90
178
  } catch (e) {
91
179
  throw new SolanaWalletUnavailable(`cannot read ${path}: ${(e as Error).message}`);
92
180
  }
93
181
  } else {
94
- throw new SolanaWalletUnavailable('neither SOLO_SOLANA_KEYPAIR nor SOLO_SOLANA_KEYPAIR_PATH is set');
182
+ throw new SolanaWalletUnavailable(
183
+ 'no wallet source configured (checked SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH, SOLO_SOLANA_KEYPAIR, SOLO_SOLANA_KEYPAIR_PATH)',
184
+ );
95
185
  }
96
186
 
97
187
  const secretKey = parseKeypairBytes(raw);
98
188
  const { Keypair } = await import('@solana/web3.js');
99
189
  const kp = Keypair.fromSecretKey(secretKey);
100
- return { publicKey: kp.publicKey.toBase58(), secretKey };
190
+ cachedWallet = { publicKey: kp.publicKey.toBase58(), secretKey };
191
+ return cachedWallet;
101
192
  }
102
193
 
103
194
  /** Whether a Solana wallet is configured, without throwing. */
104
195
  export function hasSolanaWallet(): boolean {
105
196
  return Boolean(
106
- (process.env.SOLO_SOLANA_KEYPAIR ?? '').trim() ||
197
+ cachedWallet ||
198
+ ((process.env.SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH ?? '').trim() &&
199
+ (process.env.SOLO_SOLANA_KEYPAIR_PASSWORD_FILE ?? '').trim()) ||
200
+ (process.env.SOLO_SOLANA_KEYPAIR ?? '').trim() ||
107
201
  (process.env.SOLO_SOLANA_KEYPAIR_PATH ?? '').trim(),
108
202
  );
109
203
  }
@@ -3,41 +3,74 @@ import { apiGet, apiPatch, apiPost } from '../api/client.js';
3
3
 
4
4
  /**
5
5
  * Shared JSON-schema for Mission.response_schema, used by both create_mission (set at creation)
6
- * and update_mission_questions (set/replace afterward, pre-hiring). v1 kinds only — text/choice/
7
- * rating input; kinds needing video-stimulus/gating infra (ab/slider/rank/tags/timestamps/upload)
8
- * aren't supported yet.
6
+ * and update_mission_questions (set/replace afterward, pre-hiring).
7
+ *
8
+ * Two families of kind:
9
+ * - Generic form kinds (single/multi/likert/stars/short/long/dropdown/checkbox) — usable on any
10
+ * mission type, self-contained text/choice/rating input.
11
+ * - Media-anchored kinds (timestamp_tag/image_region/video_region_duration) — need an actual
12
+ * track to annotate (a point in time, a region of an image, a region over a time range of a
13
+ * video), so they're only accepted on media_review missions. create_mission/
14
+ * update_mission_questions reject them on every other type.
15
+ * Kinds needing infra this platform doesn't have yet ('ab'/'slider'/'rank'/'tags'/'upload') aren't
16
+ * supported.
9
17
  */
10
18
  const RESPONSE_SCHEMA_PROPERTY = {
11
19
  type: 'array' as const,
12
20
  maxItems: 20,
13
21
  description:
14
- 'Structured completion questions a human answers to complete the mission. When set, ' +
15
- 'finalize_qualification auto-derives qualified UIDs from completion instead of requiring an ' +
16
- 'explicit qualified_human_uids list — same mechanism media_review has always used for track ' +
17
- 'ratings, generalized to any mission type. media_review defaults to a canonical ' +
18
- '{rating (stars), comment (long, optional)} schema when this is omitted at creation; every ' +
19
- 'other type has no default (stays fully manual/chat-based unless you set this).',
22
+ 'Structured completion questions a human answers to complete the mission. Question ids must ' +
23
+ 'be unique within one schema — a duplicate id is rejected at creation/update, not merged or ' +
24
+ 'silently overwritten. When set, finalize_qualification auto-derives qualified UIDs from ' +
25
+ 'completion instead of requiring an explicit qualified_human_uids list — same mechanism ' +
26
+ 'media_review has always used for track ratings, generalized to any mission type. ' +
27
+ 'media_review defaults to a canonical two-question schema when this is omitted at creation — ' +
28
+ 'literally { id: "rating", kind: "stars", required: true } and { id: "comment", kind: "long", ' +
29
+ 'required: false } — so read/append against those exact ids if you rely on the default rather ' +
30
+ 'than supplying your own schema. Every other type has no default (stays fully manual/chat-based ' +
31
+ 'unless you set this).',
20
32
  items: {
21
33
  type: 'object',
22
34
  properties: {
23
- id: { type: 'string', description: 'Unique id within this schema — the key answers are stored under.' },
35
+ id: { type: 'string', description: 'Unique id within this schema — the key answers are stored under. Duplicates are rejected, not merged.' },
24
36
  kind: {
25
37
  type: 'string',
26
- enum: ['single', 'multi', 'likert', 'stars', 'short', 'long', 'dropdown', 'consent'],
38
+ enum: [
39
+ 'single', 'multi', 'likert', 'stars', 'short', 'long', 'dropdown', 'checkbox',
40
+ 'timestamp_tag', 'image_region', 'video_region_duration',
41
+ ],
27
42
  description:
28
- 'single=one choice, multi=several choices, likert=5-point agreement scale, stars=1-5 rating, ' +
29
- 'short=one line of text, long=paragraph text (see min_length), dropdown=one choice from a long ' +
30
- 'list, consent=a checkbox that must be explicitly true.',
43
+ 'single=one choice, multi=several choices, likert=a scale rendered as a row of buttons (NOT ' +
44
+ 'fixed at 5 points — the number of options you supply IS the number of points, so give exactly ' +
45
+ 'as many option labels as the scale should have; a common convention is 5 labels like "1 ' +
46
+ 'Strongly disagree".."5 Strongly agree" but nothing enforces that), stars=1-5 rating (fixed, ' +
47
+ 'no options), short=one line of text, long=paragraph text (see min_length), dropdown=one ' +
48
+ 'choice from a long list, checkbox=a checkbox that must be explicitly true (usable for ANY ' +
49
+ 'yes/no question, not just consent-flavored ones) — the checkbox\'s own clickable text is ' +
50
+ '`label` itself (there is no separate heading rendered above it, unlike every other kind); ' +
51
+ 'use `help` for any additional wording you want shown near it.\n\n' +
52
+ 'Media-anchored kinds — media_review only, need a track to annotate (options/min_length ' +
53
+ 'don\'t apply; see min_count instead):\n' +
54
+ ' timestamp_tag: the human flags a moment in an audio/video track while it plays. Each ' +
55
+ 'flag is { t (seconds), tag (category — one of `options` if you set it, otherwise any ' +
56
+ 'non-empty string), note? }. `options` is OPTIONAL here (unlike the choice kinds above) — ' +
57
+ 'omit it for free-form tagging, or set it to offer a closed category list.\n' +
58
+ ' image_region: the human draws a rectangle on an image track and comments on it. Each ' +
59
+ 'region is { x, y, width, height (all 0-1, fraction of the image, not pixels), comment }.\n' +
60
+ ' video_region_duration: like image_region, but the rectangle is held for a single time ' +
61
+ 'range rather than a single point — { start, end (seconds, end > start), x, y, width, ' +
62
+ 'height, comment }. One fixed box for the whole range, not tracked frame by frame.',
31
63
  },
32
- label: { type: 'string', description: 'Question text shown to the human (max 200 chars).' },
64
+ label: { type: 'string', description: 'Question text shown to the human (max 200 chars). For the checkbox kind this is the checkbox\'s own clickable text, not a separate heading.' },
33
65
  help: { type: 'string', description: 'Optional helper text shown under the label (max 500 chars).' },
34
66
  required: { type: 'boolean', description: 'Whether this question must be answered to complete the mission.' },
35
67
  options: {
36
68
  type: 'array',
37
69
  items: { type: 'string' },
38
- description: 'Required for single/multi/dropdown/likert (max 20 options, 50 chars each). Not accepted by other kinds.',
70
+ description: 'Required (non-empty) for single/multi/dropdown/likert (max 20 options, 50 chars each). Optional for timestamp_tag (a closed category list; omit for free-form tagging). Not accepted by any other kind.',
39
71
  },
40
- min_length: { type: 'number', description: "Minimum answer length, only meaningful for the 'long' kind." },
72
+ min_length: { type: 'number', description: "Minimum answer length. Only read for the 'long' kind — silently ignored (not rejected) if set on any other kind." },
73
+ min_count: { type: 'number', description: 'Minimum number of annotations required. Only read for timestamp_tag/image_region/video_region_duration (defaults to 1 if required and unset) — silently ignored on any other kind.' },
41
74
  },
42
75
  required: ['id', 'kind', 'label', 'required'],
43
76
  },
@@ -94,7 +127,10 @@ export const missionTools: Tool[] = [
94
127
  description:
95
128
  "Set or replace a mission's response_schema after creation. Locked once hiring starts — " +
96
129
  'same window as add_mission_track (on-chain: only before funding; off-chain: only while ' +
97
- 'active with zero hired participants yet). Replaces the entire schema, not a merge.',
130
+ 'active with zero hired participants yet). This window is what makes a schema replacement ' +
131
+ 'safe: a human can only submit answers once hired, and the schema can no longer change once ' +
132
+ 'anyone has been hired — there is no state where a human has answered under one schema and ' +
133
+ 'the mission later presents a different one. Replaces the entire schema, not a merge.',
98
134
  inputSchema: {
99
135
  type: 'object',
100
136
  properties: {
@@ -282,7 +282,22 @@ export async function handleSolanaTool(
282
282
  program_id: string;
283
283
  mints: Record<string, string>;
284
284
  };
285
- const mint = cfg.mints.TEST_USDC ?? Object.values(cfg.mints)[0];
285
+ // The mission's ACTUAL mint, not a guessed default — get_solana_config's mints always
286
+ // includes both the production payout mint (USDC) and TEST_USDC, and a mission is funded
287
+ // in exactly one of them. Guessing (the previous code preferred TEST_USDC whenever present)
288
+ // computes the wrong associated-token-account for any mission funded in the other one, and
289
+ // the refund/cancel/emergency-refund transaction then fails on-chain with
290
+ // AccountNotInitialized — confirmed live against a real USDC-funded mission.
291
+ const mission = (await apiGet(`/agent/missions/${args.mission_id}`)) as {
292
+ mission?: { token_address?: string };
293
+ };
294
+ const mint = mission.mission?.token_address;
295
+ if (!mint) {
296
+ return {
297
+ refunded: false,
298
+ error: `Could not resolve mission ${args.mission_id}'s token_address — call get_mission to check it exists and is funded.`,
299
+ };
300
+ }
286
301
  const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
287
302
 
288
303
  // No action: ask what is legal rather than guessing and getting a 409. Uses a deliberately
@@ -1,90 +0,0 @@
1
- // src/solana/wallet.ts
2
- import { readFileSync } from "fs";
3
- var SolanaWalletUnavailable = class extends Error {
4
- constructor(reason) {
5
- super(
6
- `Solana wallet unavailable: ${reason}
7
-
8
- Set one of:
9
- SOLO_SOLANA_KEYPAIR - JSON byte array, as \`solana-keygen\` writes it
10
- SOLO_SOLANA_KEYPAIR_PATH - path to that file (e.g. ~/.config/solana/id.json)
11
-
12
- The wallet needs SOL for rent and fees, and USDC for the mission budget. Rent is a
13
- refundable deposit, not a fee: most of it returns when the task is closed.`
14
- );
15
- this.name = "SolanaWalletUnavailable";
16
- }
17
- };
18
- function parseKeypairBytes(raw) {
19
- const trimmed = raw.trim();
20
- if (!trimmed.startsWith("[")) {
21
- throw new SolanaWalletUnavailable(
22
- "value is not a JSON byte array \u2014 this is the format `solana-keygen new` writes"
23
- );
24
- }
25
- let parsed;
26
- try {
27
- parsed = JSON.parse(trimmed);
28
- } catch {
29
- throw new SolanaWalletUnavailable("value looks like a JSON array but does not parse");
30
- }
31
- if (!Array.isArray(parsed) || !parsed.every((n) => typeof n === "number")) {
32
- throw new SolanaWalletUnavailable("JSON array must contain only numbers");
33
- }
34
- const bytes = Uint8Array.from(parsed);
35
- if (bytes.length !== 64) {
36
- throw new SolanaWalletUnavailable(
37
- `expected 64 bytes, got ${bytes.length}` + (bytes.length === 32 ? " \u2014 this is the seed alone, not the full keypair" : "")
38
- );
39
- }
40
- return bytes;
41
- }
42
- async function loadSolanaWallet() {
43
- const inline = process.env.SOLO_SOLANA_KEYPAIR;
44
- const path = process.env.SOLO_SOLANA_KEYPAIR_PATH;
45
- let raw;
46
- if (inline && inline.trim() !== "") {
47
- raw = inline;
48
- } else if (path && path.trim() !== "") {
49
- try {
50
- raw = readFileSync(path.replace(/^~/, process.env.HOME ?? "~"), "utf8");
51
- } catch (e) {
52
- throw new SolanaWalletUnavailable(`cannot read ${path}: ${e.message}`);
53
- }
54
- } else {
55
- throw new SolanaWalletUnavailable("neither SOLO_SOLANA_KEYPAIR nor SOLO_SOLANA_KEYPAIR_PATH is set");
56
- }
57
- const secretKey = parseKeypairBytes(raw);
58
- const { Keypair } = await import("@solana/web3.js");
59
- const kp = Keypair.fromSecretKey(secretKey);
60
- return { publicKey: kp.publicKey.toBase58(), secretKey };
61
- }
62
- function hasSolanaWallet() {
63
- return Boolean(
64
- (process.env.SOLO_SOLANA_KEYPAIR ?? "").trim() || (process.env.SOLO_SOLANA_KEYPAIR_PATH ?? "").trim()
65
- );
66
- }
67
- async function associatedTokenAddress(mint, owner) {
68
- const { PublicKey } = await import("@solana/web3.js");
69
- const TOKEN_PROGRAM_ID = new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
70
- const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL");
71
- const [address] = PublicKey.findProgramAddressSync(
72
- [new PublicKey(owner).toBuffer(), TOKEN_PROGRAM_ID.toBuffer(), new PublicKey(mint).toBuffer()],
73
- ASSOCIATED_TOKEN_PROGRAM_ID
74
- );
75
- return address.toBase58();
76
- }
77
- async function signTransaction(transactionBase64, wallet) {
78
- const { Keypair, Transaction } = await import("@solana/web3.js");
79
- const kp = Keypair.fromSecretKey(wallet.secretKey);
80
- const tx = Transaction.from(Buffer.from(transactionBase64, "base64"));
81
- tx.partialSign(kp);
82
- return tx.serialize().toString("base64");
83
- }
84
- export {
85
- SolanaWalletUnavailable,
86
- associatedTokenAddress,
87
- hasSolanaWallet,
88
- loadSolanaWallet,
89
- signTransaction
90
- };