trackly-cli 0.2.2 → 0.2.4
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/bin/trackly +37 -5
- package/lib/client.js +90 -4
- package/mcp/server.js +20 -3
- package/package.json +1 -1
- package/server.json +2 -2
package/bin/trackly
CHANGED
|
@@ -512,11 +512,20 @@ async function cmdAsk(args) {
|
|
|
512
512
|
}
|
|
513
513
|
|
|
514
514
|
if (result.jobsUrl) {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
515
|
+
// Path allowlist: /ask can return any string for jobsUrl. normalizeEndpoint already
|
|
516
|
+
// blocks cross-origin fetches (baseUrl mismatch), but a compromised backend could
|
|
517
|
+
// still emit a same-origin path like `/api/admin/secret-dump`. Restrict to the two
|
|
518
|
+
// legitimate handlers that /ask is designed to route to.
|
|
519
|
+
const JOBS_URL_ALLOWLIST = /^\/api\/(v1|jobscout)\/jobs(\?|$)/;
|
|
520
|
+
if (!JOBS_URL_ALLOWLIST.test(result.jobsUrl)) {
|
|
521
|
+
console.log(color('dim', `Refusing to follow unexpected jobsUrl: ${result.jobsUrl}`));
|
|
522
|
+
} else {
|
|
523
|
+
console.log();
|
|
524
|
+
console.log(color('dim', `Fetching: ${result.jobsUrl}`));
|
|
525
|
+
const jobsResult = await apiRequest('GET', result.jobsUrl);
|
|
526
|
+
console.log();
|
|
527
|
+
outputJobs(jobsResult.jobs || jobsResult.data || []);
|
|
528
|
+
}
|
|
520
529
|
}
|
|
521
530
|
} catch (e) {
|
|
522
531
|
if (e.status === 429) setErrorExit('Daily limit reached (20 natural language queries per day)');
|
|
@@ -807,6 +816,29 @@ async function main() {
|
|
|
807
816
|
|
|
808
817
|
if (args.command !== 'config' && args['api-key']) {
|
|
809
818
|
process.env.TRACKLY_API_KEY = String(args['api-key']);
|
|
819
|
+
// Warn once per invocation: --api-key on the command line is visible in `ps auxww` to
|
|
820
|
+
// any local user AND lands in shell history (~/.zsh_history, ~/.bash_history). This
|
|
821
|
+
// warning is a nudge, not a block — scripted pipelines can suppress via TRACKLY_NO_WARN=1.
|
|
822
|
+
// Printed to stderr (not stdout) so `--json` consumers parsing stdout are unaffected.
|
|
823
|
+
if (process.env.TRACKLY_NO_WARN !== '1') {
|
|
824
|
+
process.stderr.write(
|
|
825
|
+
'\x1b[33mWarning:\x1b[0m --api-key on the command line is visible in `ps` and shell history.\n' +
|
|
826
|
+
' Prefer: TRACKLY_API_KEY=trk_... trackly ... (env var)\n' +
|
|
827
|
+
' or: trackly config --api-key trk_... (persisted to ~/.trackly/config.json, mode 0600)\n' +
|
|
828
|
+
' Silence this warning: TRACKLY_NO_WARN=1\n'
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
// Scrub the flag value from argv so subsequent inspection (diagnostics, crash reporters,
|
|
832
|
+
// child processes that inherit argv) cannot recover the secret. The `ps` vector is
|
|
833
|
+
// already lost the moment the process started — this only protects against in-process
|
|
834
|
+
// leaks (e.g. an MCP tool accidentally logging process.argv).
|
|
835
|
+
for (let i = 0; i < process.argv.length; i++) {
|
|
836
|
+
if (process.argv[i] === '--api-key' && i + 1 < process.argv.length) {
|
|
837
|
+
process.argv[i + 1] = '***';
|
|
838
|
+
} else if (process.argv[i].startsWith('--api-key=')) {
|
|
839
|
+
process.argv[i] = '--api-key=***';
|
|
840
|
+
}
|
|
841
|
+
}
|
|
810
842
|
}
|
|
811
843
|
|
|
812
844
|
if (args.command !== 'config' && args['base-url']) {
|
package/lib/client.js
CHANGED
|
@@ -54,7 +54,20 @@ function ensureConfigDir() {
|
|
|
54
54
|
function saveConfig(config) {
|
|
55
55
|
ensureConfigDir();
|
|
56
56
|
const { file } = getConfigPaths();
|
|
57
|
-
|
|
57
|
+
// Atomic write: stage to a sibling temp file (same dir → same filesystem → rename is atomic
|
|
58
|
+
// on POSIX) then rename into place. Without this, a crash or SIGKILL mid-write can leave
|
|
59
|
+
// a zero-byte or partial config.json, and the next CLI invocation silently loses all auth.
|
|
60
|
+
// Temp file is created mode 0600 so tokens are never world-readable, even briefly.
|
|
61
|
+
const tmp = `${file}.tmp.${process.pid}`;
|
|
62
|
+
fs.writeFileSync(tmp, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
63
|
+
try { fs.chmodSync(tmp, 0o600); } catch (e) {}
|
|
64
|
+
try {
|
|
65
|
+
fs.renameSync(tmp, file);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
// Best-effort cleanup of the temp file if rename fails; re-raise the original error.
|
|
68
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
69
|
+
throw e;
|
|
70
|
+
}
|
|
58
71
|
try { fs.chmodSync(file, 0o600); } catch (e) {}
|
|
59
72
|
}
|
|
60
73
|
|
|
@@ -63,6 +76,28 @@ function clearConfig() {
|
|
|
63
76
|
try { fs.unlinkSync(file); } catch (e) {}
|
|
64
77
|
}
|
|
65
78
|
|
|
79
|
+
// Surgical alternative to clearConfig() for the refresh-failure path. Deletes ONLY the
|
|
80
|
+
// OAuth credentials (token + refreshToken) while preserving everything else on disk
|
|
81
|
+
// (baseUrl, apiKey, timeout overrides, future keys). A refresh token being invalidated
|
|
82
|
+
// is not a reason to reset the user's `trackly config --base-url` choice — use this
|
|
83
|
+
// instead of clearConfig() when the only thing known to be dead is the session pair.
|
|
84
|
+
// Bug caught by Cursor Bugbot on PR #20: clearConfig() in refresh failure paths
|
|
85
|
+
// silently wiped baseUrl alongside the dead tokens.
|
|
86
|
+
function clearOAuthTokens() {
|
|
87
|
+
const config = loadConfig();
|
|
88
|
+
delete config.token;
|
|
89
|
+
delete config.refreshToken;
|
|
90
|
+
// If nothing else is persisted (e.g. user only ever logged in via OAuth and never set
|
|
91
|
+
// a custom baseUrl), unlink the file so getConfigPaths().file doesn't linger as an
|
|
92
|
+
// empty {} on disk. Otherwise preserve the remaining keys.
|
|
93
|
+
if (Object.keys(config).length === 0) {
|
|
94
|
+
const { file } = getConfigPaths();
|
|
95
|
+
try { fs.unlinkSync(file); } catch (e) {}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
saveConfig(config);
|
|
99
|
+
}
|
|
100
|
+
|
|
66
101
|
function getBaseUrl() {
|
|
67
102
|
return process.env.TRACKLY_BASE_URL || loadConfig().baseUrl || DEFAULT_BASE_URL;
|
|
68
103
|
}
|
|
@@ -169,7 +204,25 @@ async function refreshAccessToken() {
|
|
|
169
204
|
saveConfig(config);
|
|
170
205
|
return result.token;
|
|
171
206
|
}
|
|
172
|
-
|
|
207
|
+
// Backend responded 2xx but didn't return a token (e.g. { success: false, code: 'invalid_grant' }).
|
|
208
|
+
// Treat as an invalidated session and purge ONLY the OAuth pair so we don't retry on
|
|
209
|
+
// every subsequent 401. Without this, a backend that signals failure via JSON instead of
|
|
210
|
+
// a 4xx status would leave the user stuck in a doomed-refresh loop forever.
|
|
211
|
+
// Use clearOAuthTokens() — NOT clearConfig() — so the user's baseUrl, apiKey, and any
|
|
212
|
+
// other persisted settings survive a token expiry. (Bug caught by Cursor Bugbot on
|
|
213
|
+
// PR #20 post-review: the earlier clearConfig() call silently wiped baseUrl too.)
|
|
214
|
+
try { clearOAuthTokens(); } catch (_) {}
|
|
215
|
+
} catch (e) {
|
|
216
|
+
// A 401/invalid_grant on refresh means the token is permanently dead (rotation, manual
|
|
217
|
+
// revocation, or session expired). Keeping it on disk makes every subsequent request
|
|
218
|
+
// trigger another doomed refresh attempt — the user looks perpetually broken until they
|
|
219
|
+
// `trackly logout`. Network errors (ECONNREFUSED, DNS) are transient and should NOT
|
|
220
|
+
// purge the token. Surgical OAuth-only clear preserves baseUrl + apiKey + timeout overrides.
|
|
221
|
+
const status = e && typeof e.status === 'number' ? e.status : null;
|
|
222
|
+
if (status === 400 || status === 401 || status === 403) {
|
|
223
|
+
try { clearOAuthTokens(); } catch (_) {}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
173
226
|
return null;
|
|
174
227
|
}
|
|
175
228
|
|
|
@@ -209,9 +262,34 @@ function apiRequest(method, endpoint, data = null, skipAuth = false, _isRetry =
|
|
|
209
262
|
};
|
|
210
263
|
|
|
211
264
|
const req = lib.request(options, (res) => {
|
|
265
|
+
// Hard cap on response body size. A compromised / misconfigured backend (or a user-set
|
|
266
|
+
// TRACKLY_BASE_URL pointing at a malicious host) could stream unbounded data and OOM
|
|
267
|
+
// the long-lived MCP process. 10 MB is orders of magnitude larger than any real
|
|
268
|
+
// Trackly response; jobs-list maxes out around ~200 KB for 50 items.
|
|
269
|
+
const MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
212
270
|
let body = '';
|
|
213
|
-
|
|
271
|
+
let bytesReceived = 0;
|
|
272
|
+
let aborted = false;
|
|
273
|
+
// Handle socket-torn-down errors during streaming (happens when req.destroy()
|
|
274
|
+
// fires to abort an oversized body). Without this, the orphan 'error' event on
|
|
275
|
+
// the response stream can surface as an uncaught exception and crash the long-
|
|
276
|
+
// lived MCP process. The request-level `req.on('error', reject)` below catches
|
|
277
|
+
// the same underlying socket error for the request side; this covers the response
|
|
278
|
+
// side. (CodeRabbit PR #21.)
|
|
279
|
+
res.on('error', reject);
|
|
280
|
+
res.on('data', (chunk) => {
|
|
281
|
+
if (aborted) return;
|
|
282
|
+
bytesReceived += chunk.length;
|
|
283
|
+
if (bytesReceived > MAX_BODY_BYTES) {
|
|
284
|
+
aborted = true;
|
|
285
|
+
req.destroy(new Error(`Trackly response body exceeded ${MAX_BODY_BYTES} bytes`));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
body += chunk;
|
|
289
|
+
});
|
|
214
290
|
res.on('end', async () => {
|
|
291
|
+
if (aborted) return; // req.destroy already rejected via 'error' handler
|
|
292
|
+
|
|
215
293
|
let json;
|
|
216
294
|
try { json = body ? JSON.parse(body) : {}; } catch { json = { raw: body }; }
|
|
217
295
|
|
|
@@ -227,7 +305,12 @@ function apiRequest(method, endpoint, data = null, skipAuth = false, _isRetry =
|
|
|
227
305
|
}
|
|
228
306
|
|
|
229
307
|
if (res.statusCode >= 400) {
|
|
230
|
-
|
|
308
|
+
// Spread `json` FIRST so the HTTP statusCode always wins. A backend that returns
|
|
309
|
+
// `{"status": "error"}` in the body would otherwise clobber the numeric status with
|
|
310
|
+
// a string, breaking downstream branches like `typeof e.status === 'number'` in
|
|
311
|
+
// refreshAccessToken — which would silently skip the 400/401/403 token-clear and
|
|
312
|
+
// leave dead refresh tokens on disk. (Cursor Bugbot finding #5 on PR #20.)
|
|
313
|
+
reject({ ...json, status: res.statusCode });
|
|
231
314
|
} else {
|
|
232
315
|
resolve(json);
|
|
233
316
|
}
|
|
@@ -250,18 +333,21 @@ module.exports = {
|
|
|
250
333
|
TracklyConfigError,
|
|
251
334
|
apiRequest,
|
|
252
335
|
clearConfig,
|
|
336
|
+
clearOAuthTokens,
|
|
253
337
|
ensureSecureUrl,
|
|
254
338
|
getApiKey,
|
|
255
339
|
getAuthHeader,
|
|
256
340
|
getAuthMethod,
|
|
257
341
|
getBaseUrl,
|
|
258
342
|
getConfigPaths,
|
|
343
|
+
getRefreshToken,
|
|
259
344
|
getRequestHeaders,
|
|
260
345
|
getRequestTimeoutMs,
|
|
261
346
|
getToken,
|
|
262
347
|
hasAuth,
|
|
263
348
|
loadConfig,
|
|
264
349
|
normalizeEndpoint,
|
|
350
|
+
refreshAccessToken,
|
|
265
351
|
saveConfig,
|
|
266
352
|
CONFIG_DIR: DEFAULT_CONFIG_DIR,
|
|
267
353
|
CONFIG_FILE: path.join(DEFAULT_CONFIG_DIR, 'config.json'),
|
package/mcp/server.js
CHANGED
|
@@ -226,10 +226,27 @@ function createServer() {
|
|
|
226
226
|
wrapTool(async ({ query }) => {
|
|
227
227
|
const askResult = await apiRequest('GET', `/api/jobscout/ask?q=${encodeURIComponent(query)}`, null, false, false, MCP_USER_AGENT);
|
|
228
228
|
if (askResult.jobsUrl) {
|
|
229
|
-
|
|
229
|
+
// Path allowlist: /ask returns a jobsUrl string. normalizeEndpoint already blocks
|
|
230
|
+
// cross-origin fetches, but a compromised backend could emit a same-origin path
|
|
231
|
+
// like `/api/admin/secret-dump`. Only follow the two handlers /ask is designed to
|
|
232
|
+
// route to. Mirrors the same guard in bin/trackly:cmdAsk.
|
|
233
|
+
const JOBS_URL_ALLOWLIST = /^\/api\/(v1|jobscout)\/jobs(\?|$)/;
|
|
234
|
+
if (JOBS_URL_ALLOWLIST.test(askResult.jobsUrl)) {
|
|
235
|
+
const jobsResult = await apiRequest('GET', askResult.jobsUrl, null, false, false, MCP_USER_AGENT);
|
|
236
|
+
return {
|
|
237
|
+
...askResult,
|
|
238
|
+
jobs: jobsResult.jobs || jobsResult.data || [],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
// Untrusted jobsUrl. Strip it from the returned payload so the MCP client
|
|
242
|
+
// doesn't receive (and potentially act on) a path we just refused to follow
|
|
243
|
+
// ourselves. Include a telemetry breadcrumb so the agent sees the refusal.
|
|
244
|
+
// (Copilot finding #2 on PR #21.)
|
|
245
|
+
const { jobsUrl: _refused, ...safeAskResult } = askResult;
|
|
230
246
|
return {
|
|
231
|
-
...
|
|
232
|
-
|
|
247
|
+
...safeAskResult,
|
|
248
|
+
jobsUrl: null,
|
|
249
|
+
jobsUrlRefused: true,
|
|
233
250
|
};
|
|
234
251
|
}
|
|
235
252
|
return askResult;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackly-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"mcpName": "io.github.kevinastuhuaman/trackly",
|
|
5
5
|
"description": "AI job search CLI + hosted MCP server with OAuth. 128K+ jobs, 1,900+ companies, 40+ ATS. Works with Claude, ChatGPT, Cursor, Windsurf, Codex via hosted streamable-http or local stdio.",
|
|
6
6
|
"bin": {
|
package/server.json
CHANGED
|
@@ -17,12 +17,12 @@
|
|
|
17
17
|
]
|
|
18
18
|
}
|
|
19
19
|
],
|
|
20
|
-
"version": "0.2.
|
|
20
|
+
"version": "0.2.4",
|
|
21
21
|
"packages": [
|
|
22
22
|
{
|
|
23
23
|
"registryType": "npm",
|
|
24
24
|
"identifier": "trackly-cli",
|
|
25
|
-
"version": "0.2.
|
|
25
|
+
"version": "0.2.4",
|
|
26
26
|
"runtimeHint": "npx",
|
|
27
27
|
"runtimeArguments": [
|
|
28
28
|
{
|