@zeph-to/mcp-server 1.11.0 → 1.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/crypto.d.ts +20 -5
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +74 -66
- package/dist/poll.d.ts.map +1 -1
- package/dist/poll.js +4 -2
- package/dist/sanitize.d.ts +34 -0
- package/dist/sanitize.d.ts.map +1 -0
- package/dist/sanitize.js +71 -0
- package/dist/tools/ask.d.ts.map +1 -1
- package/dist/tools/ask.js +13 -6
- package/dist/tools/input.d.ts.map +1 -1
- package/dist/tools/input.js +2 -1
- package/dist/tools/notify.d.ts.map +1 -1
- package/dist/tools/notify.js +9 -6
- package/dist/tools/prompt.d.ts.map +1 -1
- package/dist/tools/prompt.js +2 -1
- package/package.json +1 -1
package/dist/crypto.d.ts
CHANGED
|
@@ -27,13 +27,28 @@
|
|
|
27
27
|
* sensitive-but-not-secret.
|
|
28
28
|
*/
|
|
29
29
|
/**
|
|
30
|
-
* Initialize crypto
|
|
31
|
-
*
|
|
30
|
+
* Initialize crypto.
|
|
31
|
+
*
|
|
32
|
+
* The MCP server is a CONSUMER of encryption keys, not a generator. Keys
|
|
33
|
+
* are created in the Zeph app where the user explicitly opts in (Settings
|
|
34
|
+
* → Encryption). This function only imports keys that the server already
|
|
35
|
+
* has, and only when the server confirms encryption is enabled.
|
|
36
|
+
*
|
|
37
|
+
* Any other state — server says disabled, server has no keys, server is
|
|
38
|
+
* unreachable — leaves encryption OFF (cache empty, no fallback). A
|
|
39
|
+
* previous version generated and uploaded a fresh keypair on the "no keys
|
|
40
|
+
* anywhere" path; combined with a transient fetch failure, that silently
|
|
41
|
+
* turned encryption on without user consent and locked the account into
|
|
42
|
+
* an "encryption enabled" state on the server.
|
|
43
|
+
*
|
|
44
|
+
* Opt-out: `ZEPH_DISABLE_ENCRYPTION=1` forces crypto off regardless of
|
|
45
|
+
* server state — useful while cleaning up legacy state or for users who
|
|
46
|
+
* never want encryption.
|
|
47
|
+
*
|
|
32
48
|
* Safe to call concurrently — deduplicates to single init.
|
|
33
|
-
* Returns the exported public key
|
|
49
|
+
* Returns the exported public key when encryption is active, '' otherwise.
|
|
34
50
|
*
|
|
35
|
-
* NOTE: when `apiKey` is provided, `baseUrl` is required
|
|
36
|
-
* caller in a dev environment would silently upload keys to prod.
|
|
51
|
+
* NOTE: when `apiKey` is provided, `baseUrl` is required.
|
|
37
52
|
*/
|
|
38
53
|
export declare const initCrypto: (apiKey?: string, baseUrl?: string) => Promise<string>;
|
|
39
54
|
export declare const getKeyPair: () => CryptoKeyPair | null;
|
package/dist/crypto.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;
|
|
1
|
+
{"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AA4JH;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,UAAU,GAAI,SAAS,MAAM,EAAE,UAAU,MAAM,KAAG,OAAO,CAAC,MAAM,CAsE5E,CAAC;AA8BF,eAAO,MAAM,UAAU,QAAO,aAAa,GAAG,IAAqB,CAAC;AACpE,eAAO,MAAM,YAAY,QAAO,MAAM,GAAG,IAA+B,CAAC;AAEzE;;GAEG;AACH,eAAO,MAAM,sBAAsB,GACjC,OAAO;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,KACrD,OAAO,CAAC;IACT,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,IAAI,CAAC;CACnB,CAaA,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,kBAAkB,GAC7B,SAAS,MAAM,KACd,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAQlE,CAAC"}
|
package/dist/crypto.js
CHANGED
|
@@ -52,14 +52,8 @@ const fromBase64 = (base64) => {
|
|
|
52
52
|
};
|
|
53
53
|
// ─── ECDH key management ───
|
|
54
54
|
const ECDH_PARAMS = { name: 'ECDH', namedCurve: 'P-256' };
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const [publicRaw, privateRaw] = await Promise.all([
|
|
58
|
-
crypto.subtle.exportKey('spki', keyPair.publicKey),
|
|
59
|
-
crypto.subtle.exportKey('pkcs8', keyPair.privateKey),
|
|
60
|
-
]);
|
|
61
|
-
return { publicKey: toBase64(publicRaw), privateKey: toBase64(privateRaw) };
|
|
62
|
-
};
|
|
55
|
+
// generateKeyPair / exportKeyPair were removed in fix/no-auto-encryption.
|
|
56
|
+
// This module imports keys only; it never creates or exports them.
|
|
63
57
|
const importPublicKey = async (base64) => crypto.subtle.importKey('spki', fromBase64(base64), ECDH_PARAMS, true, []);
|
|
64
58
|
const importPrivateKey = async (base64) => crypto.subtle.importKey('pkcs8', fromBase64(base64), ECDH_PARAMS, true, ['deriveKey', 'deriveBits']);
|
|
65
59
|
const importKeyPair = async (exported) => {
|
|
@@ -117,80 +111,102 @@ const storeKeys = (exported) => {
|
|
|
117
111
|
(0, fs_1.mkdirSync)(KEYS_DIR, { recursive: true, mode: 0o700 });
|
|
118
112
|
(0, fs_1.writeFileSync)(KEYS_PATH, JSON.stringify(exported, null, 2), { mode: 0o600 });
|
|
119
113
|
};
|
|
114
|
+
const deleteStoredKeys = () => {
|
|
115
|
+
try {
|
|
116
|
+
(0, fs_1.unlinkSync)(KEYS_PATH);
|
|
117
|
+
}
|
|
118
|
+
catch { /* not present — fine */ }
|
|
119
|
+
};
|
|
120
|
+
const envIsTrue = (key) => {
|
|
121
|
+
const v = process.env[key];
|
|
122
|
+
return !!v && /^(1|true|yes|on)$/i.test(v.trim());
|
|
123
|
+
};
|
|
120
124
|
// ─── Cached state ───
|
|
121
125
|
let cachedKeyPair = null;
|
|
122
126
|
let cachedExportedPublicKey = null;
|
|
123
127
|
let cachedOwnPublicKey = null;
|
|
124
128
|
let initPromise = null;
|
|
125
129
|
/**
|
|
126
|
-
* Initialize crypto
|
|
127
|
-
*
|
|
130
|
+
* Initialize crypto.
|
|
131
|
+
*
|
|
132
|
+
* The MCP server is a CONSUMER of encryption keys, not a generator. Keys
|
|
133
|
+
* are created in the Zeph app where the user explicitly opts in (Settings
|
|
134
|
+
* → Encryption). This function only imports keys that the server already
|
|
135
|
+
* has, and only when the server confirms encryption is enabled.
|
|
136
|
+
*
|
|
137
|
+
* Any other state — server says disabled, server has no keys, server is
|
|
138
|
+
* unreachable — leaves encryption OFF (cache empty, no fallback). A
|
|
139
|
+
* previous version generated and uploaded a fresh keypair on the "no keys
|
|
140
|
+
* anywhere" path; combined with a transient fetch failure, that silently
|
|
141
|
+
* turned encryption on without user consent and locked the account into
|
|
142
|
+
* an "encryption enabled" state on the server.
|
|
143
|
+
*
|
|
144
|
+
* Opt-out: `ZEPH_DISABLE_ENCRYPTION=1` forces crypto off regardless of
|
|
145
|
+
* server state — useful while cleaning up legacy state or for users who
|
|
146
|
+
* never want encryption.
|
|
147
|
+
*
|
|
128
148
|
* Safe to call concurrently — deduplicates to single init.
|
|
129
|
-
* Returns the exported public key
|
|
149
|
+
* Returns the exported public key when encryption is active, '' otherwise.
|
|
130
150
|
*
|
|
131
|
-
* NOTE: when `apiKey` is provided, `baseUrl` is required
|
|
132
|
-
* caller in a dev environment would silently upload keys to prod.
|
|
151
|
+
* NOTE: when `apiKey` is provided, `baseUrl` is required.
|
|
133
152
|
*/
|
|
134
153
|
const initCrypto = (apiKey, baseUrl) => {
|
|
154
|
+
// Hard opt-out — skip everything, leave cache empty.
|
|
155
|
+
if (envIsTrue('ZEPH_DISABLE_ENCRYPTION')) {
|
|
156
|
+
cachedKeyPair = null;
|
|
157
|
+
cachedExportedPublicKey = null;
|
|
158
|
+
cachedOwnPublicKey = null;
|
|
159
|
+
return Promise.resolve('');
|
|
160
|
+
}
|
|
135
161
|
if (apiKey && !baseUrl) {
|
|
136
162
|
return Promise.reject(new Error('initCrypto: baseUrl is required when apiKey is provided. ' +
|
|
137
|
-
'Pass the resolved config.baseUrl to avoid
|
|
163
|
+
'Pass the resolved config.baseUrl to avoid talking to the wrong environment.'));
|
|
138
164
|
}
|
|
139
165
|
if (initPromise)
|
|
140
166
|
return initPromise;
|
|
141
|
-
// The check above guarantees baseUrl is defined when apiKey is — narrow once.
|
|
142
167
|
const baseUrlRequired = apiKey ? baseUrl : baseUrl;
|
|
143
168
|
initPromise = (async () => {
|
|
144
|
-
const stored = loadStoredKeys();
|
|
145
|
-
// Try server sync if API key available
|
|
146
169
|
if (apiKey) {
|
|
147
170
|
const serverResult = await fetchServerKeys(apiKey, baseUrlRequired);
|
|
148
|
-
//
|
|
149
|
-
|
|
171
|
+
// The only path that turns encryption ON: server confirms enabled AND
|
|
172
|
+
// hands us a real keypair. Everything else leaves the cache empty.
|
|
173
|
+
const haveServerKeys = !!serverResult && serverResult.encryptionEnabled && !!serverResult.keys;
|
|
174
|
+
if (!haveServerKeys) {
|
|
150
175
|
cachedKeyPair = null;
|
|
151
176
|
cachedExportedPublicKey = null;
|
|
152
177
|
cachedOwnPublicKey = null;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (
|
|
157
|
-
|
|
178
|
+
// If the server is reachable and explicitly says encryption is off,
|
|
179
|
+
// drop any stale local cache so a future regression can't resurrect
|
|
180
|
+
// a keypair that the user already disabled.
|
|
181
|
+
if (serverResult && !serverResult.encryptionEnabled) {
|
|
182
|
+
deleteStoredKeys();
|
|
158
183
|
}
|
|
159
|
-
|
|
160
|
-
cachedExportedPublicKey = serverResult.keys.publicKey;
|
|
161
|
-
cachedOwnPublicKey = cachedKeyPair.publicKey;
|
|
162
|
-
return serverResult.keys.publicKey;
|
|
184
|
+
return '';
|
|
163
185
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
cachedOwnPublicKey = cachedKeyPair.publicKey;
|
|
169
|
-
return stored.publicKey;
|
|
186
|
+
const keys = serverResult.keys;
|
|
187
|
+
const stored = loadStoredKeys();
|
|
188
|
+
if (!stored || stored.publicKey !== keys.publicKey) {
|
|
189
|
+
storeKeys(keys);
|
|
170
190
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
storeKeys(exported);
|
|
174
|
-
await uploadServerKeys(exported, apiKey, baseUrlRequired);
|
|
175
|
-
cachedKeyPair = keyPair;
|
|
176
|
-
cachedExportedPublicKey = exported.publicKey;
|
|
177
|
-
cachedOwnPublicKey = keyPair.publicKey;
|
|
178
|
-
return exported.publicKey;
|
|
179
|
-
}
|
|
180
|
-
// No API key — local-only mode
|
|
181
|
-
if (stored) {
|
|
182
|
-
cachedKeyPair = await importKeyPair(stored);
|
|
183
|
-
cachedExportedPublicKey = stored.publicKey;
|
|
191
|
+
cachedKeyPair = await importKeyPair(keys);
|
|
192
|
+
cachedExportedPublicKey = keys.publicKey;
|
|
184
193
|
cachedOwnPublicKey = cachedKeyPair.publicKey;
|
|
185
|
-
return
|
|
194
|
+
return keys.publicKey;
|
|
186
195
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
196
|
+
// Local-only mode (no apiKey): load stored keys if they exist; do NOT
|
|
197
|
+
// generate. Used by tests and offline / pre-provisioned setups where
|
|
198
|
+
// a keypair has been dropped into ~/.config/zeph/keys.json out-of-band.
|
|
199
|
+
const stored = loadStoredKeys();
|
|
200
|
+
if (!stored) {
|
|
201
|
+
cachedKeyPair = null;
|
|
202
|
+
cachedExportedPublicKey = null;
|
|
203
|
+
cachedOwnPublicKey = null;
|
|
204
|
+
return '';
|
|
205
|
+
}
|
|
206
|
+
cachedKeyPair = await importKeyPair(stored);
|
|
207
|
+
cachedExportedPublicKey = stored.publicKey;
|
|
208
|
+
cachedOwnPublicKey = cachedKeyPair.publicKey;
|
|
209
|
+
return stored.publicKey;
|
|
194
210
|
})().catch((err) => {
|
|
195
211
|
initPromise = null;
|
|
196
212
|
throw err;
|
|
@@ -216,17 +232,9 @@ const fetchServerKeys = async (apiKey, baseUrl) => {
|
|
|
216
232
|
return null;
|
|
217
233
|
}
|
|
218
234
|
};
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
await fetch(url, {
|
|
223
|
-
method: 'PUT',
|
|
224
|
-
headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
|
|
225
|
-
body: JSON.stringify(keys),
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
catch { /* non-critical */ }
|
|
229
|
-
};
|
|
235
|
+
// uploadServerKeys was removed in fix/no-auto-encryption — the MCP server
|
|
236
|
+
// must never write to /users/me/keys. Keys are created by the Zeph app
|
|
237
|
+
// where the user explicitly opts in.
|
|
230
238
|
const getKeyPair = () => cachedKeyPair;
|
|
231
239
|
exports.getKeyPair = getKeyPair;
|
|
232
240
|
const getPublicKey = () => cachedExportedPublicKey;
|
package/dist/poll.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"poll.d.ts","sourceRoot":"","sources":["../src/poll.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEpD,MAAM,WAAW,WAAW;IAC1B,KAAK,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IAE5C,gBAAgB,EAAE,CAAC,YAAY,EAAE,GAAG,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACxD;AAOD,eAAO,MAAM,eAAe,GAC1B,QAAQ,aAAa,EACrB,QAAQ,MAAM,EACd,SAAS,MAAM,EACf,gBAAgB,MAAM,EACtB,KAAK,WAAW,KACf,OAAO,CAAC,iBAAiB,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"poll.d.ts","sourceRoot":"","sources":["../src/poll.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEpD,MAAM,WAAW,WAAW;IAC1B,KAAK,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IAE5C,gBAAgB,EAAE,CAAC,YAAY,EAAE,GAAG,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACxD;AAOD,eAAO,MAAM,eAAe,GAC1B,QAAQ,aAAa,EACrB,QAAQ,MAAM,EACd,SAAS,MAAM,EACf,gBAAgB,MAAM,EACtB,KAAK,WAAW,KACf,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAwClC,CAAC"}
|
package/dist/poll.js
CHANGED
|
@@ -33,8 +33,10 @@ const pollForResponse = async (client, hookId, eventId, timeoutSeconds, ctx) =>
|
|
|
33
33
|
});
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
|
-
// Adaptive interval:
|
|
37
|
-
|
|
36
|
+
// Adaptive interval: poll every 1s while the user is likely still at
|
|
37
|
+
// their device (first ~60 attempts ≈ 1 min), then back off to 3s for
|
|
38
|
+
// long waits. The tight 1s window keeps post-tap detection snappy.
|
|
39
|
+
const interval = attempt < 60 ? 1000 : 3000;
|
|
38
40
|
await sleep(interval);
|
|
39
41
|
attempt++;
|
|
40
42
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defensive cleanup for tool arguments.
|
|
3
|
+
*
|
|
4
|
+
* An agent occasionally emits a malformed tool call where a parameter's
|
|
5
|
+
* closing tag is wrong, so the serialized markup of the *following*
|
|
6
|
+
* parameters bleeds into an earlier string argument.
|
|
7
|
+
*
|
|
8
|
+
* Observed in the wild — a zeph_ask `body` arrived as:
|
|
9
|
+
*
|
|
10
|
+
* "...Commit now or test first?</body>
|
|
11
|
+
* <parameter name=\"actions\">[{\"id\":\"commit\",...}]"
|
|
12
|
+
*
|
|
13
|
+
* The actions JSON leaked into `body`, and `actions` itself arrived
|
|
14
|
+
* undefined — so the push showed the raw markup and no buttons.
|
|
15
|
+
*
|
|
16
|
+
* sanitizeText strips the leaked markup; recoverActions pulls the
|
|
17
|
+
* swallowed actions array back out so the call still works.
|
|
18
|
+
*/
|
|
19
|
+
export interface RecoveredAction {
|
|
20
|
+
id: string;
|
|
21
|
+
label: string;
|
|
22
|
+
style?: 'primary' | 'secondary' | 'danger';
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Strip leaked tool-call markup from a free-text argument. Returns the
|
|
26
|
+
* text unchanged when no leak is detected.
|
|
27
|
+
*/
|
|
28
|
+
export declare const sanitizeText: (text: string | undefined) => string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Recover an `actions` array that leaked into the body of a malformed
|
|
31
|
+
* zeph_ask call. Returns undefined when nothing parseable is found.
|
|
32
|
+
*/
|
|
33
|
+
export declare const recoverActions: (text: string | undefined) => RecoveredAction[] | undefined;
|
|
34
|
+
//# sourceMappingURL=sanitize.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sanitize.d.ts","sourceRoot":"","sources":["../src/sanitize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,SAAS,GAAG,WAAW,GAAG,QAAQ,CAAC;CAC5C;AAeD;;;GAGG;AACH,eAAO,MAAM,YAAY,GAAI,MAAM,MAAM,GAAG,SAAS,KAAG,MAAM,GAAG,SAIhE,CAAC;AASF;;;GAGG;AACH,eAAO,MAAM,cAAc,GAAI,MAAM,MAAM,GAAG,SAAS,KAAG,eAAe,EAAE,GAAG,SAgB7E,CAAC"}
|
package/dist/sanitize.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Defensive cleanup for tool arguments.
|
|
4
|
+
*
|
|
5
|
+
* An agent occasionally emits a malformed tool call where a parameter's
|
|
6
|
+
* closing tag is wrong, so the serialized markup of the *following*
|
|
7
|
+
* parameters bleeds into an earlier string argument.
|
|
8
|
+
*
|
|
9
|
+
* Observed in the wild — a zeph_ask `body` arrived as:
|
|
10
|
+
*
|
|
11
|
+
* "...Commit now or test first?</body>
|
|
12
|
+
* <parameter name=\"actions\">[{\"id\":\"commit\",...}]"
|
|
13
|
+
*
|
|
14
|
+
* The actions JSON leaked into `body`, and `actions` itself arrived
|
|
15
|
+
* undefined — so the push showed the raw markup and no buttons.
|
|
16
|
+
*
|
|
17
|
+
* sanitizeText strips the leaked markup; recoverActions pulls the
|
|
18
|
+
* swallowed actions array back out so the call still works.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.recoverActions = exports.sanitizeText = void 0;
|
|
22
|
+
// Markers that should never appear in a real notification body — their
|
|
23
|
+
// presence means tool-call markup has leaked in. Cut at the earliest one.
|
|
24
|
+
const LEAK_MARKERS = ['</body>', '</parameter>', '<parameter name=', '<parameter '];
|
|
25
|
+
const findLeakStart = (text) => {
|
|
26
|
+
let earliest = -1;
|
|
27
|
+
for (const marker of LEAK_MARKERS) {
|
|
28
|
+
const idx = text.indexOf(marker);
|
|
29
|
+
if (idx !== -1 && (earliest === -1 || idx < earliest))
|
|
30
|
+
earliest = idx;
|
|
31
|
+
}
|
|
32
|
+
return earliest;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Strip leaked tool-call markup from a free-text argument. Returns the
|
|
36
|
+
* text unchanged when no leak is detected.
|
|
37
|
+
*/
|
|
38
|
+
const sanitizeText = (text) => {
|
|
39
|
+
if (!text)
|
|
40
|
+
return text;
|
|
41
|
+
const cut = findLeakStart(text);
|
|
42
|
+
return cut === -1 ? text : text.slice(0, cut).trimEnd();
|
|
43
|
+
};
|
|
44
|
+
exports.sanitizeText = sanitizeText;
|
|
45
|
+
const isActionArray = (value) => Array.isArray(value) &&
|
|
46
|
+
value.length > 0 &&
|
|
47
|
+
value.every((a) => a && typeof a.id === 'string' && typeof a.label === 'string');
|
|
48
|
+
/**
|
|
49
|
+
* Recover an `actions` array that leaked into the body of a malformed
|
|
50
|
+
* zeph_ask call. Returns undefined when nothing parseable is found.
|
|
51
|
+
*/
|
|
52
|
+
const recoverActions = (text) => {
|
|
53
|
+
if (!text)
|
|
54
|
+
return undefined;
|
|
55
|
+
const marker = text.search(/<(?:antml:)?parameter name="actions">/);
|
|
56
|
+
if (marker === -1)
|
|
57
|
+
return undefined;
|
|
58
|
+
const after = text.slice(marker);
|
|
59
|
+
const start = after.indexOf('[');
|
|
60
|
+
const end = after.lastIndexOf(']');
|
|
61
|
+
if (start === -1 || end <= start)
|
|
62
|
+
return undefined;
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(after.slice(start, end + 1));
|
|
65
|
+
return isActionArray(parsed) ? parsed : undefined;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
exports.recoverActions = recoverActions;
|
package/dist/tools/ask.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ask.d.ts","sourceRoot":"","sources":["../../src/tools/ask.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"ask.d.ts","sourceRoot":"","sources":["../../src/tools/ask.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAwBrE,eAAO,MAAM,eAAe,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,SAgIhG,CAAC"}
|
package/dist/tools/ask.js
CHANGED
|
@@ -7,6 +7,7 @@ const poll_js_1 = require("../poll.js");
|
|
|
7
7
|
const config_js_1 = require("../config.js");
|
|
8
8
|
const crypto_js_1 = require("../crypto.js");
|
|
9
9
|
const mime_js_1 = require("../mime.js");
|
|
10
|
+
const sanitize_js_1 = require("../sanitize.js");
|
|
10
11
|
// The device feed shows a short preview of the body. Anything longer than
|
|
11
12
|
// this gets truncated there, so we attach the full text as a file — the
|
|
12
13
|
// user can always open the complete content instead of squinting at a
|
|
@@ -63,16 +64,22 @@ const registerAskTool = (server, client, config) => {
|
|
|
63
64
|
return (0, error_format_js_1.hookNotConfiguredError)();
|
|
64
65
|
try {
|
|
65
66
|
const pushTitle = (0, config_js_1.formatPushTitle)(config.projectName, title);
|
|
67
|
+
// Defend against malformed tool calls where the actions array leaked
|
|
68
|
+
// into the body (a mis-closed `body` parameter). Recover the actions
|
|
69
|
+
// from the raw body first, then strip the leaked markup. Without this
|
|
70
|
+
// the push arrives with no buttons and raw markup in the text.
|
|
71
|
+
const effectiveActions = actions && actions.length > 0 ? actions : (0, sanitize_js_1.recoverActions)(body);
|
|
72
|
+
const cleanBody = (0, sanitize_js_1.sanitizeText)(body);
|
|
66
73
|
// Attach a file whenever the body would be clipped in the feed preview.
|
|
67
|
-
const exceedsPreview = !!
|
|
68
|
-
let triggerBody =
|
|
74
|
+
const exceedsPreview = !!cleanBody && cleanBody.length > PREVIEW_LENGTH;
|
|
75
|
+
let triggerBody = cleanBody;
|
|
69
76
|
let files;
|
|
70
|
-
if (exceedsPreview &&
|
|
77
|
+
if (exceedsPreview && cleanBody) {
|
|
71
78
|
const fileName = 'response.md';
|
|
72
79
|
const fileType = (0, mime_js_1.inferMimeType)(fileName);
|
|
73
80
|
const canEncrypt = !!(0, crypto_js_1.getKeyPair)() && !!(0, crypto_js_1.getPublicKey)();
|
|
74
81
|
// Self-contained Markdown so the file alone tells the whole story.
|
|
75
|
-
const fileMarkdown = buildAskMarkdown(title,
|
|
82
|
+
const fileMarkdown = buildAskMarkdown(title, cleanBody, effectiveActions);
|
|
76
83
|
const fileBytes = new TextEncoder().encode(fileMarkdown).byteLength;
|
|
77
84
|
let uploadContent = fileMarkdown;
|
|
78
85
|
let uploadContentType = fileType;
|
|
@@ -92,13 +99,13 @@ const registerAskTool = (server, client, config) => {
|
|
|
92
99
|
}
|
|
93
100
|
const upload = await client.requestUpload({ fileName, fileType: uploadContentType, fileSize: typeof uploadContent === 'string' ? fileBytes : uploadContent.length });
|
|
94
101
|
await client.uploadToS3(upload.data.uploadUrl, uploadContent, uploadContentType);
|
|
95
|
-
triggerBody =
|
|
102
|
+
triggerBody = cleanBody.slice(0, PREVIEW_LENGTH) + '...';
|
|
96
103
|
files = [{ fileKey: upload.data.fileKey, fileName, fileSize: fileBytes, fileType, iv: fileIv, encryptedKey: fileEncryptedKey }];
|
|
97
104
|
}
|
|
98
105
|
const trigger = await client.triggerHook(config.hookId, {
|
|
99
106
|
title: pushTitle,
|
|
100
107
|
body: triggerBody,
|
|
101
|
-
actions,
|
|
108
|
+
actions: effectiveActions,
|
|
102
109
|
timeout,
|
|
103
110
|
fallback,
|
|
104
111
|
hookType: 'combo',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"input.d.ts","sourceRoot":"","sources":["../../src/tools/input.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"input.d.ts","sourceRoot":"","sources":["../../src/tools/input.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAGrE,eAAO,MAAM,iBAAiB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,SAwDlG,CAAC"}
|
package/dist/tools/input.js
CHANGED
|
@@ -5,6 +5,7 @@ const zod_1 = require("zod");
|
|
|
5
5
|
const error_format_js_1 = require("../error-format.js");
|
|
6
6
|
const poll_js_1 = require("../poll.js");
|
|
7
7
|
const config_js_1 = require("../config.js");
|
|
8
|
+
const sanitize_js_1 = require("../sanitize.js");
|
|
8
9
|
const registerInputTool = (server, client, config) => {
|
|
9
10
|
server.registerTool('zeph_input', {
|
|
10
11
|
description: 'Request text input from the user via push notification. The tool blocks until the user responds or the timeout is reached. Requires ZEPH_HOOK_ID environment variable.',
|
|
@@ -34,7 +35,7 @@ const registerInputTool = (server, client, config) => {
|
|
|
34
35
|
try {
|
|
35
36
|
const trigger = await client.triggerHook(config.hookId, {
|
|
36
37
|
title: (0, config_js_1.formatPushTitle)(config.projectName, title),
|
|
37
|
-
body,
|
|
38
|
+
body: (0, sanitize_js_1.sanitizeText)(body),
|
|
38
39
|
timeout,
|
|
39
40
|
hookType: 'input',
|
|
40
41
|
metadata: { placeholder, inputType },
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"notify.d.ts","sourceRoot":"","sources":["../../src/tools/notify.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"notify.d.ts","sourceRoot":"","sources":["../../src/tools/notify.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AASrE,eAAO,MAAM,kBAAkB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,SAmHnG,CAAC"}
|
package/dist/tools/notify.js
CHANGED
|
@@ -6,6 +6,7 @@ const error_format_js_1 = require("../error-format.js");
|
|
|
6
6
|
const config_js_1 = require("../config.js");
|
|
7
7
|
const crypto_js_1 = require("../crypto.js");
|
|
8
8
|
const mime_js_1 = require("../mime.js");
|
|
9
|
+
const sanitize_js_1 = require("../sanitize.js");
|
|
9
10
|
// The device feed shows a short preview of the body. Anything longer gets
|
|
10
11
|
// truncated there, so we attach the full text as a file for full viewing.
|
|
11
12
|
const PREVIEW_LENGTH = 200;
|
|
@@ -31,14 +32,16 @@ const registerNotifyTool = (server, client, config) => {
|
|
|
31
32
|
try {
|
|
32
33
|
const deviceId = targetDeviceId ?? config.deviceId;
|
|
33
34
|
const pushTitle = (0, config_js_1.formatPushTitle)(config.projectName, title);
|
|
35
|
+
// Strip any tool-call markup that leaked into the body argument.
|
|
36
|
+
const cleanBody = (0, sanitize_js_1.sanitizeText)(body);
|
|
34
37
|
// Attach a file whenever the body would be clipped in the feed preview.
|
|
35
|
-
const isLongBody = !!
|
|
38
|
+
const isLongBody = !!cleanBody && cleanBody.length > PREVIEW_LENGTH;
|
|
36
39
|
const canEncrypt = !!(0, crypto_js_1.getKeyPair)() && !!(0, crypto_js_1.getPublicKey)();
|
|
37
|
-
if (isLongBody &&
|
|
40
|
+
if (isLongBody && cleanBody) {
|
|
38
41
|
const fileName = 'response.md';
|
|
39
42
|
const fileType = (0, mime_js_1.inferMimeType)(fileName);
|
|
40
43
|
// Self-contained Markdown so the file alone carries the full text.
|
|
41
|
-
const fileMarkdown = `# ${title}\n\n${
|
|
44
|
+
const fileMarkdown = `# ${title}\n\n${cleanBody}`;
|
|
42
45
|
const fileBytes = new TextEncoder().encode(fileMarkdown).byteLength;
|
|
43
46
|
// Encrypt file content if keys available
|
|
44
47
|
let uploadContent = fileMarkdown;
|
|
@@ -59,7 +62,7 @@ const registerNotifyTool = (server, client, config) => {
|
|
|
59
62
|
}
|
|
60
63
|
const upload = await client.requestUpload({ fileName, fileType: uploadContentType, fileSize: typeof uploadContent === 'string' ? fileBytes : uploadContent.length });
|
|
61
64
|
await client.uploadToS3(upload.data.uploadUrl, uploadContent, uploadContentType);
|
|
62
|
-
const preview =
|
|
65
|
+
const preview = cleanBody.slice(0, PREVIEW_LENGTH) + '...';
|
|
63
66
|
// Encrypt push body (title/preview/url) if keys available
|
|
64
67
|
let pushPayload = {
|
|
65
68
|
title: pushTitle,
|
|
@@ -86,7 +89,7 @@ const registerNotifyTool = (server, client, config) => {
|
|
|
86
89
|
// Short body — encrypt push only
|
|
87
90
|
let pushPayload = {
|
|
88
91
|
title: pushTitle,
|
|
89
|
-
body,
|
|
92
|
+
body: cleanBody,
|
|
90
93
|
url,
|
|
91
94
|
type: 'hook',
|
|
92
95
|
priority,
|
|
@@ -95,7 +98,7 @@ const registerNotifyTool = (server, client, config) => {
|
|
|
95
98
|
};
|
|
96
99
|
if (canEncrypt) {
|
|
97
100
|
try {
|
|
98
|
-
const enc = await (0, crypto_js_1.encryptPushBodyForSelf)({ title: pushTitle, body, url });
|
|
101
|
+
const enc = await (0, crypto_js_1.encryptPushBodyForSelf)({ title: pushTitle, body: cleanBody, url });
|
|
99
102
|
pushPayload = { ...pushPayload, title: undefined, body: enc.body, isEncrypted: enc.isEncrypted, encryptedKey: enc.encryptedKey, senderPublicKey: enc.senderPublicKey };
|
|
100
103
|
}
|
|
101
104
|
catch (err) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"prompt.d.ts","sourceRoot":"","sources":["../../src/tools/prompt.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"prompt.d.ts","sourceRoot":"","sources":["../../src/tools/prompt.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAGrE,eAAO,MAAM,kBAAkB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,SAuEnG,CAAC"}
|
package/dist/tools/prompt.js
CHANGED
|
@@ -5,6 +5,7 @@ const zod_1 = require("zod");
|
|
|
5
5
|
const error_format_js_1 = require("../error-format.js");
|
|
6
6
|
const poll_js_1 = require("../poll.js");
|
|
7
7
|
const config_js_1 = require("../config.js");
|
|
8
|
+
const sanitize_js_1 = require("../sanitize.js");
|
|
8
9
|
const registerPromptTool = (server, client, config) => {
|
|
9
10
|
server.registerTool('zeph_prompt', {
|
|
10
11
|
description: 'Ask the user to choose from predefined options via push notification. The tool blocks until the user responds or the timeout is reached. Requires ZEPH_HOOK_ID environment variable.',
|
|
@@ -43,7 +44,7 @@ const registerPromptTool = (server, client, config) => {
|
|
|
43
44
|
try {
|
|
44
45
|
const trigger = await client.triggerHook(config.hookId, {
|
|
45
46
|
title: (0, config_js_1.formatPushTitle)(config.projectName, title),
|
|
46
|
-
body,
|
|
47
|
+
body: (0, sanitize_js_1.sanitizeText)(body),
|
|
47
48
|
actions,
|
|
48
49
|
timeout,
|
|
49
50
|
fallback,
|
package/package.json
CHANGED