@projectsolo/solo-mission-mcp 0.21.10 → 0.21.13
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 +50 -14
- package/dist/wallet-V4T4NTCM.js +143 -0
- package/package.json +1 -1
- package/src/solana/fixtures/test-keypair.json.enc +2 -0
- package/src/solana/fixtures/test-keypair.pubkey +1 -0
- package/src/solana/wallet.test.ts +45 -0
- package/src/solana/wallet.ts +101 -7
- package/src/tools/missions.ts +34 -10
- package/src/tools/tracks.test.ts +151 -0
- package/src/tools/tracks.ts +44 -5
- package/dist/wallet-IUQWBW6F.js +0 -90
package/dist/index.js
CHANGED
|
@@ -26,18 +26,31 @@ var RESPONSE_SCHEMA_PROPERTY = {
|
|
|
26
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: [
|
|
30
|
-
|
|
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). For the
|
|
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 (non-empty) for single/multi/dropdown/likert (max 20 options, 50 chars each). Not accepted by other
|
|
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 read for the 'long' kind \u2014 silently ignored (not rejected) if set on any other 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
|
}
|
|
@@ -82,7 +95,7 @@ var missionTools = [
|
|
|
82
95
|
lottery_prize_per_winner: { type: "number", minimum: 0, description: "Additional prize in USDC paid to each lottery winner on top of base_reward. Must be paired with lottery_winner_count." },
|
|
83
96
|
hiring_duration_hours: { type: "number", minimum: 0.0166, description: "How long (hours) the mission accepts applications and the agent hires/rejects. The hiring window closes at now + hiring_duration_hours. Finalize-qualification cannot be called before this. Backend floor is 60s (0.0166h), same for every chain \u2014 see work_duration_hours for the reasoning." },
|
|
84
97
|
work_duration_hours: { type: "number", minimum: 0.0166, description: "How long (hours) hired participants have to complete the work. Agent must call settle_mission before this period ends. Backend floor is 60s (0.0166h) for every chain \u2014 this is a flat sanity check against a near-zero window, not a guarantee of a legal settlement window on every chain: Base's own EscrowVault contract separately enforces its own fixed 1-hour minimum regardless of what this floor allows through, so a too-short Base mission still fails downstream (at finalize_qualification, or on-chain) instead of being caught at creation." },
|
|
85
|
-
auto_accept_applicants: { type: "boolean", description: "
|
|
98
|
+
auto_accept_applicants: { type: "boolean", description: "Defaults to true \u2014 applicants are automatically hired when they apply, no manual hire_participant call needed, first-come first-served up to max_humans, face verification still required. Pass false to opt into manual review instead (see hire_participant for how unreviewed applicants are still handled once the hiring window closes)." },
|
|
86
99
|
response_schema: RESPONSE_SCHEMA_PROPERTY
|
|
87
100
|
},
|
|
88
101
|
required: ["type", "title", "description"]
|
|
@@ -862,17 +875,25 @@ async function handleAgentTool(name, args) {
|
|
|
862
875
|
}
|
|
863
876
|
|
|
864
877
|
// src/tools/tracks.ts
|
|
878
|
+
import { readFile } from "fs/promises";
|
|
865
879
|
var trackTools = [
|
|
866
880
|
{
|
|
867
881
|
name: "add_mission_track",
|
|
868
|
-
description: "Upload a media item (audio, image, or video) to a media_review mission. Provide the file
|
|
882
|
+
description: "Upload a media item (audio, image, or video) to a media_review mission. Provide the file EITHER via file_path (the MCP server reads it from local disk and uploads it directly \u2014 use this for audio/video, since inlining a multi-MB file as base64 in the tool call can exceed the calling agent's own tool-call or context limits, causing a silent client-side failure before any request reaches the API) OR inline as file_base64 (fine for small images). Exactly one of the two is required. For on-chain missions, call this BEFORE confirm_funding \u2014 uploads are blocked once the mission is active. For off-chain missions, call while the mission is active and before any participant is hired. The item becomes visible to hired participants once confirmed.",
|
|
869
883
|
inputSchema: {
|
|
870
884
|
type: "object",
|
|
871
885
|
properties: {
|
|
872
886
|
mission_id: { type: "string", description: "ID of the media_review mission" },
|
|
873
887
|
title: { type: "string", description: "Item title (required)" },
|
|
874
888
|
artist: { type: "string", description: "Artist / creator name (optional; typically used for audio)" },
|
|
875
|
-
|
|
889
|
+
file_path: {
|
|
890
|
+
type: "string",
|
|
891
|
+
description: "Path to a local file to upload, as an alternative to file_base64. The MCP server reads this file itself and PUTs it to the signed upload URL, so the calling agent only needs to pass the path \u2014 not the file contents. Prefer this for audio/video files. Use an absolute path: a relative path resolves against the MCP server process's working directory, not the caller's. Exactly one of file_base64 or file_path is required."
|
|
892
|
+
},
|
|
893
|
+
file_base64: {
|
|
894
|
+
type: "string",
|
|
895
|
+
description: "Base64-encoded file contents, as an alternative to file_path. Fine for small images; for audio/video prefer file_path \u2014 encoding a multi-MB file inline can exceed the calling agent's own tool-call or context limits. Exactly one of file_base64 or file_path is required."
|
|
896
|
+
},
|
|
876
897
|
content_type: {
|
|
877
898
|
type: "string",
|
|
878
899
|
enum: ["audio/mpeg", "audio/mp4", "image/jpeg", "image/png", "image/webp", "video/mp4"],
|
|
@@ -880,7 +901,7 @@ var trackTools = [
|
|
|
880
901
|
},
|
|
881
902
|
duration_seconds: { type: "number", description: "Duration in seconds (optional; applicable to audio and video only)" }
|
|
882
903
|
},
|
|
883
|
-
required: ["mission_id", "title", "
|
|
904
|
+
required: ["mission_id", "title", "content_type"]
|
|
884
905
|
}
|
|
885
906
|
},
|
|
886
907
|
{
|
|
@@ -922,12 +943,27 @@ var trackTools = [
|
|
|
922
943
|
async function handleTrackTool(name, args) {
|
|
923
944
|
switch (name) {
|
|
924
945
|
case "add_mission_track": {
|
|
925
|
-
const { mission_id, title, artist, file_base64, content_type, duration_seconds } = args;
|
|
946
|
+
const { mission_id, title, artist, file_base64, file_path, content_type, duration_seconds } = args;
|
|
947
|
+
if (!file_base64 && !file_path) {
|
|
948
|
+
throw new Error("add_mission_track requires either file_base64 or file_path");
|
|
949
|
+
}
|
|
950
|
+
if (file_base64 && file_path) {
|
|
951
|
+
throw new Error("add_mission_track accepts only one of file_base64 or file_path, not both");
|
|
952
|
+
}
|
|
953
|
+
let fileBytes;
|
|
954
|
+
if (file_path) {
|
|
955
|
+
try {
|
|
956
|
+
fileBytes = await readFile(file_path);
|
|
957
|
+
} catch (err) {
|
|
958
|
+
throw new Error(`Could not read file_path "${file_path}": ${err.message}`);
|
|
959
|
+
}
|
|
960
|
+
} else {
|
|
961
|
+
fileBytes = Buffer.from(file_base64, "base64");
|
|
962
|
+
}
|
|
926
963
|
const urlRes = await apiPost(
|
|
927
964
|
`/agent/missions/${mission_id}/tracks/upload-url`,
|
|
928
965
|
{ title, artist, content_type }
|
|
929
966
|
);
|
|
930
|
-
const fileBytes = Buffer.from(file_base64, "base64");
|
|
931
967
|
const uploadRes = await fetch(urlRes.upload_url, {
|
|
932
968
|
method: "PUT",
|
|
933
969
|
headers: { "Content-Type": content_type },
|
|
@@ -1046,7 +1082,7 @@ async function handleSolanaTool(name, args) {
|
|
|
1046
1082
|
case "get_solana_config":
|
|
1047
1083
|
return apiGet2("/agent/solana/config");
|
|
1048
1084
|
case "get_solana_wallet": {
|
|
1049
|
-
const { hasSolanaWallet, loadSolanaWallet, associatedTokenAddress } = await import("./wallet-
|
|
1085
|
+
const { hasSolanaWallet, loadSolanaWallet, associatedTokenAddress } = await import("./wallet-V4T4NTCM.js");
|
|
1050
1086
|
if (!hasSolanaWallet()) {
|
|
1051
1087
|
return {
|
|
1052
1088
|
configured: false,
|
|
@@ -1092,7 +1128,7 @@ async function handleSolanaTool(name, args) {
|
|
|
1092
1128
|
};
|
|
1093
1129
|
}
|
|
1094
1130
|
case "fund_solana_mission": {
|
|
1095
|
-
const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-
|
|
1131
|
+
const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-V4T4NTCM.js");
|
|
1096
1132
|
const { verifyFundingTransaction } = await import("./verify-KAETIGV5.js");
|
|
1097
1133
|
const wallet = await loadSolanaWallet();
|
|
1098
1134
|
const cfg = await apiGet2("/agent/solana/config");
|
|
@@ -1152,7 +1188,7 @@ async function handleSolanaTool(name, args) {
|
|
|
1152
1188
|
return { funded: true, verified: true, ...confirmed };
|
|
1153
1189
|
}
|
|
1154
1190
|
case "refund_solana_mission": {
|
|
1155
|
-
const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-
|
|
1191
|
+
const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-V4T4NTCM.js");
|
|
1156
1192
|
const wallet = await loadSolanaWallet();
|
|
1157
1193
|
const cfg = await apiGet2("/agent/solana/config");
|
|
1158
1194
|
const mission = await apiGet2(`/agent/missions/${args.mission_id}`);
|
|
@@ -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
|
@@ -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
|
+
});
|
package/src/solana/wallet.ts
CHANGED
|
@@ -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
|
-
'
|
|
38
|
-
'
|
|
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 (
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/tools/missions.ts
CHANGED
|
@@ -3,9 +3,17 @@ 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).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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,
|
|
@@ -27,26 +35,42 @@ const RESPONSE_SCHEMA_PROPERTY = {
|
|
|
27
35
|
id: { type: 'string', description: 'Unique id within this schema — the key answers are stored under. Duplicates are rejected, not merged.' },
|
|
28
36
|
kind: {
|
|
29
37
|
type: 'string',
|
|
30
|
-
enum: [
|
|
38
|
+
enum: [
|
|
39
|
+
'single', 'multi', 'likert', 'stars', 'short', 'long', 'dropdown', 'checkbox',
|
|
40
|
+
'timestamp_tag', 'image_region', 'video_region_duration',
|
|
41
|
+
],
|
|
31
42
|
description:
|
|
32
43
|
'single=one choice, multi=several choices, likert=a scale rendered as a row of buttons (NOT ' +
|
|
33
44
|
'fixed at 5 points — the number of options you supply IS the number of points, so give exactly ' +
|
|
34
45
|
'as many option labels as the scale should have; a common convention is 5 labels like "1 ' +
|
|
35
46
|
'Strongly disagree".."5 Strongly agree" but nothing enforces that), stars=1-5 rating (fixed, ' +
|
|
36
47
|
'no options), short=one line of text, long=paragraph text (see min_length), dropdown=one ' +
|
|
37
|
-
'choice from a long list,
|
|
38
|
-
'
|
|
39
|
-
'
|
|
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.',
|
|
40
63
|
},
|
|
41
|
-
label: { type: 'string', description: 'Question text shown to the human (max 200 chars). For the
|
|
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.' },
|
|
42
65
|
help: { type: 'string', description: 'Optional helper text shown under the label (max 500 chars).' },
|
|
43
66
|
required: { type: 'boolean', description: 'Whether this question must be answered to complete the mission.' },
|
|
44
67
|
options: {
|
|
45
68
|
type: 'array',
|
|
46
69
|
items: { type: 'string' },
|
|
47
|
-
description: 'Required (non-empty) for single/multi/dropdown/likert (max 20 options, 50 chars each). Not accepted by other
|
|
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.',
|
|
48
71
|
},
|
|
49
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.' },
|
|
50
74
|
},
|
|
51
75
|
required: ['id', 'kind', 'label', 'required'],
|
|
52
76
|
},
|
|
@@ -92,7 +116,7 @@ export const missionTools: Tool[] = [
|
|
|
92
116
|
lottery_prize_per_winner: { type: 'number', minimum: 0, description: 'Additional prize in USDC paid to each lottery winner on top of base_reward. Must be paired with lottery_winner_count.' },
|
|
93
117
|
hiring_duration_hours: { type: 'number', minimum: 0.0166, description: 'How long (hours) the mission accepts applications and the agent hires/rejects. The hiring window closes at now + hiring_duration_hours. Finalize-qualification cannot be called before this. Backend floor is 60s (0.0166h), same for every chain — see work_duration_hours for the reasoning.' },
|
|
94
118
|
work_duration_hours: { type: 'number', minimum: 0.0166, description: 'How long (hours) hired participants have to complete the work. Agent must call settle_mission before this period ends. Backend floor is 60s (0.0166h) for every chain — this is a flat sanity check against a near-zero window, not a guarantee of a legal settlement window on every chain: Base\'s own EscrowVault contract separately enforces its own fixed 1-hour minimum regardless of what this floor allows through, so a too-short Base mission still fails downstream (at finalize_qualification, or on-chain) instead of being caught at creation.' },
|
|
95
|
-
auto_accept_applicants: { type: 'boolean', description: '
|
|
119
|
+
auto_accept_applicants: { type: 'boolean', description: 'Defaults to true — applicants are automatically hired when they apply, no manual hire_participant call needed, first-come first-served up to max_humans, face verification still required. Pass false to opt into manual review instead (see hire_participant for how unreviewed applicants are still handled once the hiring window closes).' },
|
|
96
120
|
response_schema: RESPONSE_SCHEMA_PROPERTY,
|
|
97
121
|
},
|
|
98
122
|
required: ['type', 'title', 'description'],
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `add_mission_track` accepts a file two ways: base64 inline in the tool-call args, or a local
|
|
3
|
+
* file_path the MCP server reads itself. The file_path option exists because inlining a multi-MB
|
|
4
|
+
* audio/video file as base64 can blow past the CALLING AGENT's own tool-call or context limits —
|
|
5
|
+
* a client-side failure that never reaches solo-firebase at all (root-caused against mission
|
|
6
|
+
* nJskovWGfrbfVu6gU3b5, where zero upload requests hit the backend). These tests cover the
|
|
7
|
+
* validation/ordering around that new path, plus a regression check that file_base64 still works
|
|
8
|
+
* unchanged.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
const apiPost = vi.fn();
|
|
16
|
+
const apiGet = vi.fn();
|
|
17
|
+
const apiDelete = vi.fn();
|
|
18
|
+
|
|
19
|
+
vi.mock('../api/client.js', () => ({
|
|
20
|
+
apiPost: (...args: unknown[]) => apiPost(...args),
|
|
21
|
+
apiGet: (...args: unknown[]) => apiGet(...args),
|
|
22
|
+
apiDelete: (...args: unknown[]) => apiDelete(...args),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
import { handleTrackTool } from './tracks.js';
|
|
26
|
+
|
|
27
|
+
describe('add_mission_track', () => {
|
|
28
|
+
let tmpDir: string;
|
|
29
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'solo-mcp-track-'));
|
|
33
|
+
apiPost.mockReset();
|
|
34
|
+
apiGet.mockReset();
|
|
35
|
+
apiDelete.mockReset();
|
|
36
|
+
fetchMock = vi.fn().mockResolvedValue({ ok: true, text: async () => '' });
|
|
37
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
42
|
+
vi.unstubAllGlobals();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function mockHappyPathApi() {
|
|
46
|
+
apiPost.mockImplementation(async (path: string) => {
|
|
47
|
+
if (path.endsWith('/tracks/upload-url')) {
|
|
48
|
+
return { upload_url: 'https://storage.example/signed-put', storage_path: 'x/y.mp3', track_id: 'trk_1' };
|
|
49
|
+
}
|
|
50
|
+
if (path.endsWith('/confirm')) {
|
|
51
|
+
return { track: { id: 'trk_1', title: 'My Track' } };
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`Unexpected apiPost call: ${path}`);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
it('reads the file from file_path and PUTs its exact bytes to the signed URL', async () => {
|
|
58
|
+
mockHappyPathApi();
|
|
59
|
+
const filePath = join(tmpDir, 'song.mp3');
|
|
60
|
+
const contents = Buffer.from('fake mp3 bytes');
|
|
61
|
+
writeFileSync(filePath, contents);
|
|
62
|
+
|
|
63
|
+
const result = await handleTrackTool('add_mission_track', {
|
|
64
|
+
mission_id: 'm1',
|
|
65
|
+
title: 'My Track',
|
|
66
|
+
content_type: 'audio/mpeg',
|
|
67
|
+
file_path: filePath,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(result).toEqual({ id: 'trk_1', title: 'My Track' });
|
|
71
|
+
expect(apiPost).toHaveBeenCalledWith('/agent/missions/m1/tracks/upload-url', {
|
|
72
|
+
title: 'My Track',
|
|
73
|
+
artist: undefined,
|
|
74
|
+
content_type: 'audio/mpeg',
|
|
75
|
+
});
|
|
76
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
77
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
78
|
+
expect(url).toBe('https://storage.example/signed-put');
|
|
79
|
+
expect(init.method).toBe('PUT');
|
|
80
|
+
expect(Buffer.isBuffer(init.body) ? init.body : Buffer.from(init.body)).toEqual(contents);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('still accepts inline file_base64 unchanged (regression)', async () => {
|
|
84
|
+
mockHappyPathApi();
|
|
85
|
+
const contents = Buffer.from('fake image bytes');
|
|
86
|
+
|
|
87
|
+
await handleTrackTool('add_mission_track', {
|
|
88
|
+
mission_id: 'm1',
|
|
89
|
+
title: 'My Image',
|
|
90
|
+
content_type: 'image/png',
|
|
91
|
+
file_base64: contents.toString('base64'),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
95
|
+
expect(Buffer.isBuffer(init.body) ? init.body : Buffer.from(init.body)).toEqual(contents);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('rejects when neither file_base64 nor file_path is provided, without calling the API', async () => {
|
|
99
|
+
await expect(
|
|
100
|
+
handleTrackTool('add_mission_track', { mission_id: 'm1', title: 'X', content_type: 'audio/mpeg' }),
|
|
101
|
+
).rejects.toThrow(/requires either file_base64 or file_path/);
|
|
102
|
+
expect(apiPost).not.toHaveBeenCalled();
|
|
103
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('rejects when both file_base64 and file_path are provided, without calling the API', async () => {
|
|
107
|
+
await expect(
|
|
108
|
+
handleTrackTool('add_mission_track', {
|
|
109
|
+
mission_id: 'm1',
|
|
110
|
+
title: 'X',
|
|
111
|
+
content_type: 'audio/mpeg',
|
|
112
|
+
file_base64: 'YWJj',
|
|
113
|
+
file_path: join(tmpDir, 'irrelevant.mp3'),
|
|
114
|
+
}),
|
|
115
|
+
).rejects.toThrow(/only one of file_base64 or file_path/);
|
|
116
|
+
expect(apiPost).not.toHaveBeenCalled();
|
|
117
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('fails fast on an unreadable file_path, before creating a pending track doc', async () => {
|
|
121
|
+
await expect(
|
|
122
|
+
handleTrackTool('add_mission_track', {
|
|
123
|
+
mission_id: 'm1',
|
|
124
|
+
title: 'X',
|
|
125
|
+
content_type: 'audio/mpeg',
|
|
126
|
+
file_path: join(tmpDir, 'does-not-exist.mp3'),
|
|
127
|
+
}),
|
|
128
|
+
).rejects.toThrow(/Could not read file_path/);
|
|
129
|
+
// The whole point of reading the file before Step 1: no upload-url/track doc created for a bad path.
|
|
130
|
+
expect(apiPost).not.toHaveBeenCalled();
|
|
131
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('deletes the pending track doc when the signed-URL PUT fails', async () => {
|
|
135
|
+
mockHappyPathApi();
|
|
136
|
+
apiDelete.mockResolvedValue(undefined);
|
|
137
|
+
fetchMock.mockResolvedValue({ ok: false, status: 500, text: async () => 'boom' });
|
|
138
|
+
const filePath = join(tmpDir, 'song.mp3');
|
|
139
|
+
writeFileSync(filePath, 'bytes');
|
|
140
|
+
|
|
141
|
+
await expect(
|
|
142
|
+
handleTrackTool('add_mission_track', {
|
|
143
|
+
mission_id: 'm1',
|
|
144
|
+
title: 'My Track',
|
|
145
|
+
content_type: 'audio/mpeg',
|
|
146
|
+
file_path: filePath,
|
|
147
|
+
}),
|
|
148
|
+
).rejects.toThrow(/Media upload failed/);
|
|
149
|
+
expect(apiDelete).toHaveBeenCalledWith('/agent/missions/m1/tracks/trk_1');
|
|
150
|
+
});
|
|
151
|
+
});
|
package/src/tools/tracks.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
1
2
|
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
2
3
|
import { apiGet, apiPost, apiDelete } from '../api/client.js';
|
|
3
4
|
|
|
@@ -5,7 +6,11 @@ export const trackTools: Tool[] = [
|
|
|
5
6
|
{
|
|
6
7
|
name: 'add_mission_track',
|
|
7
8
|
description:
|
|
8
|
-
'Upload a media item (audio, image, or video) to a media_review mission. Provide the file
|
|
9
|
+
'Upload a media item (audio, image, or video) to a media_review mission. Provide the file EITHER via file_path ' +
|
|
10
|
+
'(the MCP server reads it from local disk and uploads it directly — use this for audio/video, since inlining a ' +
|
|
11
|
+
'multi-MB file as base64 in the tool call can exceed the calling agent\'s own tool-call or context limits, ' +
|
|
12
|
+
'causing a silent client-side failure before any request reaches the API) OR inline as file_base64 (fine for ' +
|
|
13
|
+
'small images). Exactly one of the two is required. ' +
|
|
9
14
|
'For on-chain missions, call this BEFORE confirm_funding — uploads are blocked once the mission is active. ' +
|
|
10
15
|
'For off-chain missions, call while the mission is active and before any participant is hired. ' +
|
|
11
16
|
'The item becomes visible to hired participants once confirmed.',
|
|
@@ -15,7 +20,21 @@ export const trackTools: Tool[] = [
|
|
|
15
20
|
mission_id: { type: 'string', description: 'ID of the media_review mission' },
|
|
16
21
|
title: { type: 'string', description: 'Item title (required)' },
|
|
17
22
|
artist: { type: 'string', description: 'Artist / creator name (optional; typically used for audio)' },
|
|
18
|
-
|
|
23
|
+
file_path: {
|
|
24
|
+
type: 'string',
|
|
25
|
+
description:
|
|
26
|
+
'Path to a local file to upload, as an alternative to file_base64. The MCP server reads this file itself ' +
|
|
27
|
+
'and PUTs it to the signed upload URL, so the calling agent only needs to pass the path — not the file ' +
|
|
28
|
+
'contents. Prefer this for audio/video files. Use an absolute path: a relative path resolves against the ' +
|
|
29
|
+
"MCP server process's working directory, not the caller's. Exactly one of file_base64 or file_path is required.",
|
|
30
|
+
},
|
|
31
|
+
file_base64: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
description:
|
|
34
|
+
'Base64-encoded file contents, as an alternative to file_path. Fine for small images; for audio/video ' +
|
|
35
|
+
'prefer file_path — encoding a multi-MB file inline can exceed the calling agent\'s own tool-call or ' +
|
|
36
|
+
'context limits. Exactly one of file_base64 or file_path is required.',
|
|
37
|
+
},
|
|
19
38
|
content_type: {
|
|
20
39
|
type: 'string',
|
|
21
40
|
enum: ['audio/mpeg', 'audio/mp4', 'image/jpeg', 'image/png', 'image/webp', 'video/mp4'],
|
|
@@ -27,7 +46,7 @@ export const trackTools: Tool[] = [
|
|
|
27
46
|
},
|
|
28
47
|
duration_seconds: { type: 'number', description: 'Duration in seconds (optional; applicable to audio and video only)' },
|
|
29
48
|
},
|
|
30
|
-
required: ['mission_id', 'title', '
|
|
49
|
+
required: ['mission_id', 'title', 'content_type'],
|
|
31
50
|
},
|
|
32
51
|
},
|
|
33
52
|
{
|
|
@@ -76,7 +95,28 @@ export const trackTools: Tool[] = [
|
|
|
76
95
|
export async function handleTrackTool(name: string, args: Record<string, any>): Promise<unknown> {
|
|
77
96
|
switch (name) {
|
|
78
97
|
case 'add_mission_track': {
|
|
79
|
-
const { mission_id, title, artist, file_base64, content_type, duration_seconds } = args;
|
|
98
|
+
const { mission_id, title, artist, file_base64, file_path, content_type, duration_seconds } = args;
|
|
99
|
+
|
|
100
|
+
if (!file_base64 && !file_path) {
|
|
101
|
+
throw new Error('add_mission_track requires either file_base64 or file_path');
|
|
102
|
+
}
|
|
103
|
+
if (file_base64 && file_path) {
|
|
104
|
+
throw new Error('add_mission_track accepts only one of file_base64 or file_path, not both');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Read/decode the file up front, before creating any server-side state, so a
|
|
108
|
+
// missing or unreadable local file fails fast without leaving a pending track
|
|
109
|
+
// doc that would then need cleanup.
|
|
110
|
+
let fileBytes: Buffer;
|
|
111
|
+
if (file_path) {
|
|
112
|
+
try {
|
|
113
|
+
fileBytes = await readFile(file_path);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
throw new Error(`Could not read file_path "${file_path}": ${(err as Error).message}`);
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
fileBytes = Buffer.from(file_base64, 'base64');
|
|
119
|
+
}
|
|
80
120
|
|
|
81
121
|
// Step 1: Get signed upload URL and create pending track doc
|
|
82
122
|
const urlRes = await apiPost<{ upload_url: string; storage_path: string; track_id: string }>(
|
|
@@ -87,7 +127,6 @@ export async function handleTrackTool(name: string, args: Record<string, any>):
|
|
|
87
127
|
// Step 2: Upload binary to the signed URL.
|
|
88
128
|
// On failure, delete the pending track doc so it doesn't consume the 20-track
|
|
89
129
|
// slot or appear as a ghost in list results.
|
|
90
|
-
const fileBytes = Buffer.from(file_base64, 'base64');
|
|
91
130
|
const uploadRes = await fetch(urlRes.upload_url, {
|
|
92
131
|
method: 'PUT',
|
|
93
132
|
headers: { 'Content-Type': content_type },
|
package/dist/wallet-IUQWBW6F.js
DELETED
|
@@ -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
|
-
};
|