trackly-cli 0.6.0 → 0.6.1

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/README.md CHANGED
@@ -35,7 +35,7 @@ trackly jobs --function product
35
35
 
36
36
  ## At a Glance
37
37
 
38
- 1,900+ companies | 128K+ jobs | 40+ ATS types | CLI + MCP | 20 local MCP tools
38
+ 1,900+ companies | 128K+ jobs | 40+ ATS types | CLI + MCP | 21 local MCP tools
39
39
 
40
40
  ## CLI Commands
41
41
 
@@ -173,6 +173,7 @@ Then use natural language in any of these clients:
173
173
  | trackly_report_apply_observation | Send redacted ATS mechanics feedback |
174
174
  | trackly_record_application_outcome | Record review or confirmed submission outcome |
175
175
  | trackly_prepare_resume | Prepare a private expiring resume file for upload |
176
+ | trackly_verify_prepared_resume | Recheck the confirmed resume immediately before attachment |
176
177
 
177
178
  ## Authentication
178
179
 
@@ -246,7 +247,7 @@ Install trackly-cli (`npm install -g trackly-cli`), authenticate with `trackly l
246
247
 
247
248
  **What MCP servers exist for job searching?**
248
249
 
249
- trackly-cli includes a built-in MCP server with 20 tools for job search, company lookup, application tracking, profile onboarding, and manual-submit form preparation. Run `trackly mcp` or use `trackly agent setup --client claude`.
250
+ trackly-cli includes a built-in MCP server with 21 tools for job search, company lookup, application tracking, profile onboarding, and manual-submit form preparation. Run `trackly mcp` or use `trackly agent setup --client claude`.
250
251
 
251
252
  **How do I use Claude Code for job hunting?**
252
253
 
package/bin/trackly CHANGED
@@ -1053,7 +1053,7 @@ ${color('bold', 'Filter values:')}
1053
1053
  --work-arrangement
1054
1054
  remote, hybrid, in_person, unspecified (comma-separated)
1055
1055
  --remote Flag (no value). Filters for remote jobs (equivalent to usStates=REMOTE).
1056
- --status new, applying, applied_confirmed, check_later, not_interested, all
1056
+ --status new, applied_confirmed, check_later, not_interested, all
1057
1057
  (filters by YOUR application pipeline state)
1058
1058
  --sort newest (default), match (highest match score first, requires resume)
1059
1059
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "contractVersion": "1.0.0",
2
+ "contractVersion": "2.0.0",
3
3
  "tools": {
4
4
  "trackly_get_apply_queue": "{limit:z.number().int().min(1).max(100).optional()}",
5
5
  "trackly_get_application_profile": "{includeSensitive:z.boolean().optional(),provider:z.string().max(100).optional(),companyId:z.string().max(100).optional()}",
@@ -9,6 +9,10 @@
9
9
  "trackly_get_apply_protocol": "{}",
10
10
  "trackly_report_apply_observation": "{runId:z.number().int().min(1).optional(),provider:z.string().min(1).max(100),fieldLabel:z.string().min(1).max(1000),observationType:z.string().min(1).max(100),resolutionCode:z.string().max(100).optional(),metadata:z.object({controlType:z.string().max(100).optional(),required:z.boolean().optional(),errorCode:z.string().max(100).optional(),committed:z.boolean().optional()}).optional()}",
11
11
  "trackly_record_application_outcome": "{runId:z.number().int().min(1),outcome:z.enum(['review_ready','submitted','failed','blocked']),confirmation:z.string().max(500).optional()}",
12
- "trackly_prepare_resume": "{}"
12
+ "trackly_prepare_resume": "{runId:z.number().int().min(1)}",
13
+ "trackly_verify_prepared_resume": {
14
+ "local": "{runId:z.number().int().min(1),confirmationId:z.string().min(1).max(200),exactLocalPath:z.string().min(1).max(4096),sha256:z.string().regex(/^[a-f0-9]{64}$/i),sizeBytes:z.number().int().min(1),expiresAt:z.string().datetime()}",
15
+ "hosted": "{runId:z.number().int().min(1),confirmationId:z.string().min(1).max(200)}"
16
+ }
13
17
  }
14
18
  }
@@ -75,7 +75,7 @@ You can also pass `TRACKLY_API_KEY` as an environment variable for one-off runs.
75
75
  - `jobModality`: `full_time`, `internship`, `all` — employment type, NOT work arrangement.
76
76
  - `workArrangements`: one or more of `remote`, `hybrid`, `in_person`, `unspecified`. This is an independent axis and combines with region, employment type, and function filters.
77
77
  - `remote` (boolean): filter to remote jobs only. Maps to `usStates=REMOTE`.
78
- - `status`: your application pipeline state. Values: `new`, `applied_confirmed`, `check_later`, `not_interested`, `all`. Older `applying` requests are accepted by the backend as a private compatibility alias for `check_later`, but new clients must not emit it.
78
+ - `status`: your application pipeline state. Values: `new`, `applied_confirmed`, `check_later`, `not_interested`, `all`.
79
79
  - `sort`: `newest` (default) or `match` (highest match score first; requires a resume on file). Backend rejects the deprecated values `oldest` and `company` with HTTP 400.
80
80
  - `keywords`, `companyId`, `limit`, `offset`.
81
81
  - **trackly_get_job** — Get full job details by ID
@@ -96,7 +96,10 @@ You can also pass `TRACKLY_API_KEY` as an environment variable for one-off runs.
96
96
  - **trackly_get_apply_protocol** — Get the current ATS support, browser integrity rules, and compatible skill version.
97
97
  - **trackly_report_apply_observation** — Report redacted ATS mechanics without answer values.
98
98
  - **trackly_record_application_outcome** — Record review readiness or a confirmed manual submission.
99
- - **trackly_prepare_resume** — Local MCP only: materialize the default resume in a private, expiring mode-0600 cache. Hosted MCP returns a manual/local-agent requirement.
99
+ - **trackly_prepare_resume** — Local MCP only: materialize the default resume in a private, expiring mode-0600 cache and return filename, size, SHA-256, exact local path, and visual-confirmation metadata. Hosted MCP returns a manual/local-agent requirement.
100
+ - **trackly_verify_prepared_resume** — Local MCP only: immediately before attachment, recompute the user-confirmed resume hash and size, validate the exact path/run/expiration, and lock the file read-only. Any mismatch requires a fresh preview and confirmation.
101
+
102
+ Apply contract v2 intentionally gives this verifier different local and hosted schemas: local MCP receives the full proof needed to inspect the private file, while hosted MCP accepts only run and confirmation identifiers and returns the manual/local-agent requirement. Local paths and fingerprints are never sent to the hosted verifier.
100
103
 
101
104
  ### Maintenance behavior
102
105
 
package/lib/agent.js CHANGED
@@ -7,7 +7,7 @@ const path = require('node:path');
7
7
  const { spawnSync } = require('node:child_process');
8
8
  const { apiRequest, downloadFile, getConfigPaths, hasAuth, maintenanceOutput } = require('./client');
9
9
  const { version: PACKAGE_VERSION } = require('../package.json');
10
- const SKILL_VERSION = '1.0.1';
10
+ const SKILL_VERSION = '2.0.0';
11
11
  const CLI_USER_AGENT = `trackly-cli/${PACKAGE_VERSION}`;
12
12
  const MCP_USER_AGENT = `trackly-mcp/${PACKAGE_VERSION}`;
13
13
 
@@ -15,6 +15,8 @@ const SKILL_NAME = 'trackly-apply';
15
15
  const SKILL_MAJOR = Number(SKILL_VERSION.split('.')[0]);
16
16
  const CACHE_TTL_MS = 2 * 60 * 60 * 1000;
17
17
  const MANAGED_FILE = '.trackly-managed.json';
18
+ const RESUME_PROOF_FILE = '.trackly-resume-proof.json';
19
+ const RESUME_PROOF_KEY_BYTES = 32;
18
20
 
19
21
  function bundledSkillDir() {
20
22
  return path.join(__dirname, '..', 'skills', SKILL_NAME);
@@ -292,26 +294,288 @@ function cleanResumeCache(now = Date.now()) {
292
294
  const dir = cacheDir();
293
295
  if (!fs.existsSync(dir)) return { removed: 0, directory: dir };
294
296
  let removed = 0;
295
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
296
- if (!entry.isFile()) continue;
297
- const file = path.join(dir, entry.name);
298
- const stat = fs.statSync(file);
299
- if (now - stat.mtimeMs > CACHE_TTL_MS) {
300
- fs.rmSync(file, { force: true });
301
- removed++;
297
+
298
+ function ignoreConcurrentRemoval(error, codes = ['ENOENT']) {
299
+ if (codes.includes(error?.code)) return true;
300
+ throw error;
301
+ }
302
+
303
+ function cleanDirectory(current, removeWhenExpired = false) {
304
+ let directoryStat;
305
+ try {
306
+ directoryStat = fs.lstatSync(current);
307
+ } catch (error) {
308
+ if (ignoreConcurrentRemoval(error)) return;
309
+ }
310
+ if (!directoryStat.isDirectory()) return;
311
+ const directoryExpired = removeWhenExpired && now - directoryStat.mtimeMs > CACHE_TTL_MS;
312
+ let entries;
313
+ try {
314
+ entries = fs.readdirSync(current, { withFileTypes: true });
315
+ } catch (error) {
316
+ if (ignoreConcurrentRemoval(error)) return;
317
+ }
318
+ for (const entry of entries) {
319
+ const item = path.join(current, entry.name);
320
+ if (entry.isDirectory()) {
321
+ cleanDirectory(item, true);
322
+ continue;
323
+ }
324
+ if (!entry.isFile()) continue;
325
+ let stat;
326
+ try {
327
+ stat = fs.lstatSync(item);
328
+ } catch (error) {
329
+ if (ignoreConcurrentRemoval(error)) continue;
330
+ }
331
+ if (!stat.isFile()) continue;
332
+ if (now - stat.mtimeMs > CACHE_TTL_MS) {
333
+ try {
334
+ fs.rmSync(item, { force: true });
335
+ removed++;
336
+ } catch (error) {
337
+ if (!ignoreConcurrentRemoval(error)) throw error;
338
+ }
339
+ }
340
+ }
341
+ if (!directoryExpired) return;
342
+ try {
343
+ if (fs.readdirSync(current).length === 0) fs.rmdirSync(current);
344
+ } catch (error) {
345
+ ignoreConcurrentRemoval(error, ['ENOENT', 'ENOTEMPTY']);
302
346
  }
303
347
  }
348
+
349
+ cleanDirectory(dir);
304
350
  return { removed, directory: dir };
305
351
  }
306
352
 
307
353
  function safeResumeName(fileName, contentType) {
308
354
  const extension = contentType === 'application/pdf' ? '.pdf' : '.docx';
309
- const base = path.basename(fileName || `resume${extension}`).replace(/[^A-Za-z0-9._ -]/g, '_').slice(0, 120);
310
- return base.toLowerCase().endsWith(extension) ? base : `${base}${extension}`;
355
+ const value = fileName == null || fileName === '' ? `resume${extension}` : String(fileName);
356
+ const invalid = value === '.'
357
+ || value === '..'
358
+ || value !== value.trim()
359
+ || /[\\/\0-\x1f\x7f]/.test(value)
360
+ || Buffer.byteLength(value, 'utf8') > 240
361
+ || !value.toLowerCase().endsWith(extension);
362
+ if (invalid) throw new Error('Trackly returned an unsafe or incompatible resume filename. Update the default resume and try again.');
363
+ return value;
311
364
  }
312
365
 
313
366
  function resumeCacheName(fileName, contentType, now = Date.now(), nonce = crypto.randomBytes(4).toString('hex')) {
314
- return `${now}-${nonce}-${safeResumeName(fileName, contentType)}`;
367
+ return path.join(`${now}-${nonce}`, safeResumeName(fileName, contentType));
368
+ }
369
+
370
+ function resumeConfirmation(fileName, sha256, sizeBytes, exactLocalPath, runId, expiresAt, confirmationId) {
371
+ return {
372
+ required: true,
373
+ source: 'trackly_default_resume',
374
+ runId,
375
+ confirmationId,
376
+ fileName,
377
+ sha256,
378
+ sizeBytes,
379
+ expiresAt,
380
+ verification: {
381
+ preferred: 'local_preview',
382
+ exactLocalPath,
383
+ displayExactPathRequired: true,
384
+ },
385
+ };
386
+ }
387
+
388
+ function resumeProofKey() {
389
+ const configDir = path.resolve(getConfigPaths().dir);
390
+ ensureDir(configDir, 0o700);
391
+ const keyPath = path.join(configDir, 'resume-proof.key');
392
+
393
+ function readExistingKey() {
394
+ const stat = fs.lstatSync(keyPath);
395
+ if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) {
396
+ throw new Error('The Trackly resume-proof key is not a private regular file.');
397
+ }
398
+ const key = fs.readFileSync(keyPath);
399
+ if (key.length !== RESUME_PROOF_KEY_BYTES) throw new Error('The Trackly resume-proof key is invalid.');
400
+ return key;
401
+ }
402
+
403
+ try {
404
+ return readExistingKey();
405
+ } catch (error) {
406
+ if (error?.code !== 'ENOENT') throw error;
407
+ }
408
+
409
+ const candidate = crypto.randomBytes(RESUME_PROOF_KEY_BYTES);
410
+ try {
411
+ fs.writeFileSync(keyPath, candidate, { mode: 0o600, flag: 'wx' });
412
+ fs.chmodSync(keyPath, 0o600);
413
+ return candidate;
414
+ } catch (error) {
415
+ candidate.fill(0);
416
+ if (error?.code !== 'EEXIST') throw error;
417
+ return readExistingKey();
418
+ }
419
+ }
420
+
421
+ function signedResumeProof(record) {
422
+ const payload = JSON.stringify(record);
423
+ const key = resumeProofKey();
424
+ try {
425
+ return {
426
+ ...record,
427
+ signature: crypto.createHmac('sha256', key).update(payload).digest('hex'),
428
+ };
429
+ } finally {
430
+ key.fill(0);
431
+ }
432
+ }
433
+
434
+ function verifyResumeProofSignature(manifest) {
435
+ const { signature, ...record } = manifest || {};
436
+ if (!/^[a-f0-9]{64}$/i.test(signature || '')) throw new Error('The prepared resume proof is invalid. Prepare and confirm it again.');
437
+ const key = resumeProofKey();
438
+ try {
439
+ const expected = crypto.createHmac('sha256', key).update(JSON.stringify(record)).digest();
440
+ if (!crypto.timingSafeEqual(expected, Buffer.from(signature, 'hex'))) {
441
+ throw new Error('The prepared resume proof is invalid. Prepare and confirm it again.');
442
+ }
443
+ } finally {
444
+ key.fill(0);
445
+ }
446
+ return record;
447
+ }
448
+
449
+ function materializeResume(download, options = {}) {
450
+ const runId = Number(options.runId);
451
+ if (!Number.isInteger(runId) || runId < 1) throw new Error('A valid Trackly Apply run ID is required before preparing a resume.');
452
+ const now = options.now ?? Date.now();
453
+ const nonce = options.nonce ?? crypto.randomBytes(4).toString('hex');
454
+ const confirmationId = options.confirmationId ?? crypto.randomUUID();
455
+ const validated = validateResumeFile(download);
456
+ const dir = cacheDir();
457
+ ensureDir(dir, 0o700);
458
+ const name = resumeCacheName(download.fileName, validated.contentType, now, nonce);
459
+ const destination = path.join(dir, name);
460
+ ensureDir(path.dirname(destination), 0o700);
461
+ fs.writeFileSync(destination, download.buffer, { mode: 0o600, flag: 'wx' });
462
+ try { fs.chmodSync(destination, 0o600); } catch (_) {}
463
+ const fileName = path.basename(destination);
464
+ const expiresAt = new Date(now + CACHE_TTL_MS).toISOString();
465
+ const confirmation = resumeConfirmation(
466
+ fileName,
467
+ validated.digest,
468
+ download.buffer.length,
469
+ destination,
470
+ runId,
471
+ expiresAt,
472
+ confirmationId,
473
+ );
474
+ const manifestPath = path.join(path.dirname(destination), RESUME_PROOF_FILE);
475
+ try {
476
+ const manifest = signedResumeProof({
477
+ runId,
478
+ confirmationId,
479
+ exactLocalPath: destination,
480
+ fileName,
481
+ sha256: validated.digest,
482
+ sizeBytes: download.buffer.length,
483
+ expiresAt,
484
+ });
485
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest), { mode: 0o400, flag: 'wx' });
486
+ fs.chmodSync(manifestPath, 0o400);
487
+ } catch (error) {
488
+ fs.rmSync(destination, { force: true });
489
+ try { fs.rmdirSync(path.dirname(destination)); } catch (cleanupError) {
490
+ if (!['ENOENT', 'ENOTEMPTY'].includes(cleanupError?.code)) throw cleanupError;
491
+ }
492
+ throw error;
493
+ }
494
+ return {
495
+ path: destination,
496
+ fileName,
497
+ contentType: validated.contentType,
498
+ sha256: validated.digest,
499
+ sizeBytes: download.buffer.length,
500
+ expiresAt,
501
+ confirmation,
502
+ };
503
+ }
504
+
505
+ function verifyPreparedResume(proof, now = Date.now()) {
506
+ const runId = Number(proof?.runId);
507
+ if (!Number.isInteger(runId) || runId < 1) throw new Error('A valid Trackly Apply run ID is required to verify a prepared resume.');
508
+ if (!proof?.confirmationId || typeof proof.confirmationId !== 'string') throw new Error('The prepared resume confirmation ID is required.');
509
+ if (!/^[a-f0-9]{64}$/i.test(proof?.sha256 || '')) throw new Error('The prepared resume SHA-256 is invalid.');
510
+ if (!Number.isInteger(proof?.sizeBytes) || proof.sizeBytes < 1) throw new Error('The prepared resume size is invalid.');
511
+ const expiresAtMs = Date.parse(proof?.expiresAt || '');
512
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now) throw new Error('The prepared resume confirmation expired. Prepare and confirm the resume again.');
513
+
514
+ const expectedPath = path.resolve(String(proof?.exactLocalPath || ''));
515
+ const cacheRoot = path.resolve(cacheDir());
516
+ if (!expectedPath.startsWith(`${cacheRoot}${path.sep}`)) throw new Error('The prepared resume path is outside the private Trackly cache.');
517
+
518
+ let realPath;
519
+ try {
520
+ realPath = fs.realpathSync(expectedPath);
521
+ } catch (error) {
522
+ if (error?.code === 'ENOENT') throw new Error('The prepared resume no longer exists. Prepare and confirm the resume again.');
523
+ throw error;
524
+ }
525
+ const realCacheRoot = fs.realpathSync(cacheRoot);
526
+ if (!realPath.startsWith(`${realCacheRoot}${path.sep}`)) throw new Error('The prepared resume resolves outside the private Trackly cache.');
527
+
528
+ let manifest;
529
+ try {
530
+ const manifestPath = path.join(path.dirname(expectedPath), RESUME_PROOF_FILE);
531
+ const manifestStat = fs.lstatSync(manifestPath);
532
+ if (!manifestStat.isFile() || manifestStat.isSymbolicLink() || (manifestStat.mode & 0o077) !== 0) {
533
+ throw new Error('The prepared resume proof is not private. Prepare and confirm it again.');
534
+ }
535
+ manifest = verifyResumeProofSignature(JSON.parse(fs.readFileSync(manifestPath, 'utf8')));
536
+ } catch (error) {
537
+ if (error instanceof SyntaxError || error?.code === 'ENOENT') {
538
+ throw new Error('The prepared resume proof is missing or invalid. Prepare and confirm it again.');
539
+ }
540
+ throw error;
541
+ }
542
+
543
+ const bindingMatches = manifest.runId === runId
544
+ && manifest.confirmationId === proof.confirmationId
545
+ && manifest.exactLocalPath === expectedPath
546
+ && manifest.fileName === path.basename(expectedPath)
547
+ && manifest.sha256 === proof.sha256
548
+ && manifest.sizeBytes === proof.sizeBytes
549
+ && manifest.expiresAt === proof.expiresAt;
550
+ if (!bindingMatches) throw new Error('The prepared resume proof does not match the confirmed run and file. Prepare and confirm it again.');
551
+
552
+ let stat;
553
+ try {
554
+ stat = fs.lstatSync(expectedPath);
555
+ } catch (error) {
556
+ if (error?.code === 'ENOENT') throw new Error('The prepared resume no longer exists. Prepare and confirm the resume again.');
557
+ throw error;
558
+ }
559
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('The prepared resume is not a regular private cache file.');
560
+ if ((stat.mode & 0o077) !== 0) throw new Error('The prepared resume permissions are not private.');
561
+ if (stat.size !== proof.sizeBytes) throw new Error('The prepared resume size changed after confirmation. Prepare and confirm it again.');
562
+
563
+ const sha256 = crypto.createHash('sha256').update(fs.readFileSync(expectedPath)).digest('hex');
564
+ if (!crypto.timingSafeEqual(Buffer.from(sha256, 'hex'), Buffer.from(proof.sha256, 'hex'))) {
565
+ throw new Error('The prepared resume contents changed after confirmation. Prepare and confirm it again.');
566
+ }
567
+
568
+ fs.chmodSync(expectedPath, 0o400);
569
+ return {
570
+ verified: true,
571
+ runId,
572
+ confirmationId: proof.confirmationId,
573
+ exactLocalPath: expectedPath,
574
+ sha256,
575
+ sizeBytes: stat.size,
576
+ expiresAt: new Date(expiresAtMs).toISOString(),
577
+ permissions: '400',
578
+ };
315
579
  }
316
580
 
317
581
  function validateResumeFile(download) {
@@ -333,25 +597,22 @@ function validateResumeFile(download) {
333
597
  };
334
598
  }
335
599
 
336
- async function prepareResume(userAgent = MCP_USER_AGENT) {
600
+ async function prepareResume(runId, userAgent = MCP_USER_AGENT) {
337
601
  if (!hasAuth()) throw new Error('Authenticate first with `trackly login` or `trackly config --api-key ...`.');
602
+ const normalizedRunId = Number(runId);
603
+ if (!Number.isInteger(normalizedRunId) || normalizedRunId < 1) {
604
+ throw new Error('A valid Trackly Apply run ID is required before preparing a resume.');
605
+ }
338
606
  cleanResumeCache();
339
- const download = await downloadFile('/api/jobscout/application-profile/default-resume', userAgent);
607
+ const download = await downloadFile(
608
+ `/api/jobscout/application-profile/default-resume?runId=${normalizedRunId}`,
609
+ userAgent,
610
+ );
340
611
  try {
341
- const validated = validateResumeFile(download);
342
- const dir = cacheDir();
343
- ensureDir(dir, 0o700);
344
- const name = resumeCacheName(download.fileName, validated.contentType);
345
- const destination = path.join(dir, name);
346
- fs.writeFileSync(destination, download.buffer, { mode: 0o600, flag: 'wx' });
347
- try { fs.chmodSync(destination, 0o600); } catch (_) {}
348
- return {
349
- path: destination,
350
- fileName: path.basename(destination),
351
- contentType: validated.contentType,
352
- sha256: validated.digest,
353
- expiresAt: new Date(Date.now() + CACHE_TTL_MS).toISOString(),
354
- };
612
+ if (Number(download.applyRunId) !== normalizedRunId) {
613
+ throw new Error('Trackly did not confirm that the resume belongs to the active Apply run.');
614
+ }
615
+ return materializeResume(download, { runId: normalizedRunId });
355
616
  } finally {
356
617
  download.buffer.fill(0);
357
618
  }
@@ -372,7 +633,7 @@ async function doctorAgent() {
372
633
  cache,
373
634
  protocol: null,
374
635
  profile: null,
375
- resume: { verified: false },
636
+ resume: { available: false },
376
637
  compatible: false,
377
638
  ok: false,
378
639
  };
@@ -390,24 +651,11 @@ async function doctorAgent() {
390
651
  };
391
652
  report.compatible = Number(report.protocol.compatibleSkillMajor) === SKILL_MAJOR;
392
653
  if (report.profile.hasDefaultResume) {
393
- try {
394
- const prepared = await prepareResume(CLI_USER_AGENT);
395
- const mode = fs.statSync(prepared.path).mode & 0o777;
396
- report.resume = {
397
- verified: mode === 0o600,
398
- contentType: prepared.contentType,
399
- permissions: mode.toString(8).padStart(3, '0'),
400
- expiresAt: prepared.expiresAt,
401
- };
402
- if (!report.resume.verified) report.resume.error = 'Prepared resume cache file is not mode 0600.';
403
- } catch (error) {
404
- const maintenance = maintenanceOutput(error);
405
- report.resume = {
406
- verified: false,
407
- error: error.error || error.message || String(error),
408
- ...(maintenance ? { maintenance } : {}),
409
- };
410
- }
654
+ report.resume = {
655
+ available: true,
656
+ fileName: profile.profile.defaultResume.fileName || null,
657
+ validation: 'Exact bytes are prepared and verified only after a real Apply run starts.',
658
+ };
411
659
  }
412
660
  } catch (error) {
413
661
  report.apiError = maintenanceOutput(error) || error.error || error.message || String(error);
@@ -416,14 +664,16 @@ async function doctorAgent() {
416
664
  && report.compatible
417
665
  && report.clients.some((client) => client.executable && client.installed && client.mcpRegistered)
418
666
  && Boolean(report.profile?.hasDefaultResume)
419
- && report.resume.verified
667
+ && report.resume.available
420
668
  && report.profile?.completeness?.missingKeys?.length === 0;
421
669
  return report;
422
670
  }
423
671
 
424
672
  function resumeValidationStatus(report) {
425
673
  if (!report?.profile?.hasDefaultResume) return 'not applicable (no default resume set)';
426
- return report.resume?.verified ? 'passed' : 'failed';
674
+ return report.resume?.available
675
+ ? 'available (exact bytes are verified during an active Apply run)'
676
+ : 'failed';
427
677
  }
428
678
 
429
679
  module.exports = {
@@ -439,9 +689,12 @@ module.exports = {
439
689
  clientSkillDir,
440
690
  doctorAgent,
441
691
  inspectClient,
692
+ materializeResume,
442
693
  prepareResume,
694
+ resumeConfirmation,
443
695
  resumeCacheName,
444
696
  resumeValidationStatus,
445
697
  setupAgent,
446
698
  validateResumeFile,
699
+ verifyPreparedResume,
447
700
  };
package/lib/client.js CHANGED
@@ -680,12 +680,12 @@ function downloadFile(endpoint, userAgent = null, _isRetry = false) {
680
680
  return;
681
681
  }
682
682
  const disposition = String(res.headers['content-disposition'] || '');
683
- const match = disposition.match(/filename="?([^";]+)"?/i);
684
683
  resolve({
685
684
  buffer,
686
685
  contentType: String(res.headers['content-type'] || 'application/octet-stream').split(';')[0],
687
- fileName: match ? match[1] : null,
686
+ fileName: contentDispositionFileName(disposition),
688
687
  sha256: res.headers['x-trackly-file-sha256'] || null,
688
+ applyRunId: res.headers['x-trackly-apply-run-id'] || null,
689
689
  });
690
690
  });
691
691
  });
@@ -695,6 +695,24 @@ function downloadFile(endpoint, userAgent = null, _isRetry = false) {
695
695
  });
696
696
  }
697
697
 
698
+ function contentDispositionFileName(disposition) {
699
+ const value = String(disposition || '');
700
+ const extended = value.match(/(?:^|;)\s*filename\*\s*=\s*(?:"([^"]*)"|([^;]*))/i);
701
+ if (extended) {
702
+ const encoded = extended[1] != null ? extended[1] : String(extended[2] ?? '').trim();
703
+ const match = encoded.match(/^([^']*)'[^']*'(.*)$/s);
704
+ const charset = match ? match[1].toLowerCase() : 'utf-8';
705
+ const payload = match ? match[2] : encoded;
706
+ if (charset === 'utf-8' || charset === 'utf8') {
707
+ try { return decodeURIComponent(payload); } catch (_) {}
708
+ }
709
+ }
710
+ const basic = value.match(/(?:^|;)\s*filename\s*=\s*(?:"([^"]*)"|([^;]*))/i);
711
+ if (!basic) return null;
712
+ const fileName = basic[1] != null ? basic[1] : String(basic[2] ?? '').trim();
713
+ return fileName || null;
714
+ }
715
+
698
716
  module.exports = {
699
717
  DEFAULT_BASE_URL,
700
718
  DEFAULT_CONFIG_DIR,
@@ -706,6 +724,7 @@ module.exports = {
706
724
  downloadFile,
707
725
  clearConfig,
708
726
  clearOAuthTokens,
727
+ contentDispositionFileName,
709
728
  ensureSecureUrl,
710
729
  getApiKey,
711
730
  getAuthHeader,
package/mcp/server.js CHANGED
@@ -5,7 +5,7 @@ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio
5
5
  const { McpError } = require('@modelcontextprotocol/sdk/types.js');
6
6
  const { z } = require('zod');
7
7
  const { apiRequest, hasAuth, maintenanceOutput } = require('../lib/client');
8
- const { prepareResume } = require('../lib/agent');
8
+ const { prepareResume, verifyPreparedResume } = require('../lib/agent');
9
9
  const { version: PACKAGE_VERSION } = require('../package.json');
10
10
 
11
11
  const MCP_USER_AGENT = `trackly-mcp/${PACKAGE_VERSION}`;
@@ -469,9 +469,23 @@ function createServer() {
469
469
 
470
470
  server.tool(
471
471
  'trackly_prepare_resume',
472
- 'Download the authenticated default resume into a mode-0600 temporary Trackly cache for browser upload.',
473
- {},
474
- wrapTool(async () => prepareResume(), 'Failed to prepare default resume')
472
+ 'Download the authenticated default resume into a mode-0600 temporary Trackly cache and return exact-file proof for user confirmation before browser upload.',
473
+ { runId: z.number().int().min(1) },
474
+ wrapTool(async ({ runId }) => prepareResume(runId), 'Failed to prepare default resume')
475
+ );
476
+
477
+ server.tool(
478
+ 'trackly_verify_prepared_resume',
479
+ 'Immediately before attachment, recompute the prepared resume fingerprint, validate its run and expiration, and lock the confirmed file read-only.',
480
+ {
481
+ runId: z.number().int().min(1),
482
+ confirmationId: z.string().min(1).max(200),
483
+ exactLocalPath: z.string().min(1).max(4096),
484
+ sha256: z.string().regex(/^[a-f0-9]{64}$/i),
485
+ sizeBytes: z.number().int().min(1),
486
+ expiresAt: z.string().datetime(),
487
+ },
488
+ wrapTool(async (proof) => verifyPreparedResume(proof), 'Prepared resume integrity verification failed')
475
489
  );
476
490
 
477
491
  server.registerPrompt('trackly-apply', {
@@ -482,7 +496,7 @@ function createServer() {
482
496
  role: 'user',
483
497
  content: {
484
498
  type: 'text',
485
- text: 'Fetch the Trackly Apply protocol and profile onboarding, resolve missing answers with me, start the next approved queue run, fill the form, verify every required field, and stop before Submit. If maintenance interrupts the run, retain the run and browser context, wait for the advertised window, refetch protocol and profile state, and resume the existing agent_browser run. Never start a duplicate run, blindly retry a mutation, or click Submit.',
499
+ text: 'Fetch the Trackly Apply protocol and profile onboarding, resolve missing answers with me, and start the next approved queue run. Prepare the run-bound resume locally, show me its exact path, filename, size, SHA-256, run, and expiration, and obtain my explicit confirmation. Immediately before attachment, use the local verifier to validate the signed proof, recompute hash and size, check expiration, and lock the file read-only. Then fill the form, verify every required field, and stop before Submit. If maintenance interrupts the run, retain the run and browser context, wait for the advertised window, refetch protocol and profile state, and resume the existing agent_browser run. Never start a duplicate run, blindly retry a mutation, or click Submit.',
486
500
  },
487
501
  }],
488
502
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackly-cli",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "mcpName": "io.github.trackly-app/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.6.0",
20
+ "version": "0.6.1",
21
21
  "packages": [
22
22
  {
23
23
  "registryType": "npm",
24
24
  "identifier": "trackly-cli",
25
- "version": "0.6.0",
25
+ "version": "0.6.1",
26
26
  "runtimeHint": "npx",
27
27
  "runtimeArguments": [
28
28
  {
@@ -30,7 +30,17 @@ Use Trackly as the source of truth for profile answers, documents, queue decisio
30
30
  4. Require the one-time profile confirmation, complete education entries, and a default resume before browser work.
31
31
  5. Call `trackly_get_apply_queue`. Select deterministically unless the user names a job. Do not replace the queue call with a fresh job search.
32
32
  6. Call `trackly_start_apply_run` for the selected job.
33
- 7. Call `trackly_prepare_resume`. If hosted MCP reports it unavailable, tell the user that local Trackly MCP or manual upload is required.
33
+ 7. Call `trackly_prepare_resume` with the active application run ID. If hosted MCP reports it unavailable, tell the user that local Trackly MCP or manual upload is required.
34
+ 8. Preserve the user’s filename returned by `trackly_prepare_resume`. Internal cache identifiers belong only in private parent directories and must never appear in the employer-facing upload filename.
35
+ 9. Before any upload, let the user inspect the exact prepared file returned by `trackly_prepare_resume`:
36
+ - Prefer an inline visual preview. Otherwise open the exact local file in Quick Look or Preview.app.
37
+ - Show a compact proof block with source (`Trackly default resume`), exact local path, user-facing filename, file size, SHA-256 fingerprint, application run, and expiration.
38
+ - Ask for explicit confirmation to use that resume. Bind confirmation to the exact SHA-256 and current application run; a different hash or run requires new confirmation.
39
+ - Always provide `confirmation.verification.exactLocalPath` so the user can independently verify the file. Never describe the prepared cache path as the original upload source.
40
+ - If an original local source path is known from the current session, identify it separately. Do not store original device paths in Trackly.
41
+ - A generic profile page is not proof of the prepared file. Use an app or web deep link only when the current protocol supplies an authenticated exact-resume viewer tied to the same SHA-256.
42
+ - If no exact preview method works, stop and ask the user to inspect the file manually.
43
+ 10. After the user confirms and immediately before attachment, call local `trackly_verify_prepared_resume` with the confirmed run ID, confirmation ID, exact path, SHA-256, size, and expiration. Continue only when it returns `verified: true` for exactly those values. The verifier validates the signed prepare-issued proof, recomputes the file hash, and locks it read-only. If it is unavailable, expired, missing, or mismatched, stop; prepare and visually confirm a fresh copy or require manual upload. Never send a local path or fingerprint to the hosted verifier.
34
44
 
35
45
  ## Resume after maintenance
36
46
 
@@ -50,7 +60,7 @@ Follow this order:
50
60
 
51
61
  1. Open the application in the controlled browser context and confirm the employer, role, ATS host, and HTTPS URL.
52
62
  2. Inspect the whole form and identify required fields, semantic controls, consent controls, document inputs, and multi-step sections.
53
- 3. Upload the prepared resume before autofill when parsing may overwrite contact fields. Verify the filename chip.
63
+ 3. Only after the exact-hash visual confirmation and a successful immediate pre-attach `trackly_verify_prepared_resume` check, upload the prepared resume before autofill when parsing may overwrite contact fields. Do not change the file between verification and attachment. Verify that the filename chip exactly matches the prepared resume’s user-facing filename and contains no internal cache identifier. Stop and replace the attachment if it does not.
54
64
  4. Fill typed fields from the resolved Trackly profile. Clear parser-filled data when the canonical state is intentionally blank.
55
65
  5. Use real UI clicks for React/native selects, radios, and checkboxes. After every selection, verify the committed value and disappearance of the required-field error.
56
66
  6. Recheck email and phone through both browser DOM state and macOS accessibility state. Require exact values and reject duplicate/concatenated values.
@@ -8,6 +8,10 @@ Ready for your review — not submitted
8
8
  Company / role:
9
9
  ATS / URL:
10
10
  Resume:
11
+ Resume visual confirmation: yes/no
12
+ Resume exact local path:
13
+ Resume fingerprint / size:
14
+ Resume confirmation run / expiration:
11
15
  Critical contact values verified: yes/no
12
16
  Authorization / sponsorship:
13
17
  Compensation answer: