trackly-cli 0.7.2 → 0.8.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 +30 -5
- package/bin/trackly +224 -21
- package/contracts/trackly-apply-tools.json +26 -7
- package/docs/trackly-tools.md +26 -2
- package/lib/agent.js +197 -11
- package/lib/client.js +86 -2
- package/mcp/apply-tools.js +666 -0
- package/mcp/server.js +124 -210
- package/npm-shrinkwrap.json +1177 -0
- package/package.json +7 -2
- package/scripts/verify-audit-exceptions.js +190 -0
- package/security/audit-exceptions.json +70 -0
- package/server.json +2 -2
- package/skills/trackly-apply/SKILL.md +23 -16
- package/skills/trackly-apply/agents/openai.yaml +1 -1
- package/skills/trackly-apply/references/batch-orchestration.md +187 -0
- package/skills/trackly-apply/references/browser-lifecycle.md +93 -0
- package/skills/trackly-apply/references/review-handoff.md +53 -2
package/lib/agent.js
CHANGED
|
@@ -7,15 +7,17 @@ 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
|
|
10
|
+
const { contractVersion: MCP_CONTRACT_VERSION } = require('../contracts/trackly-apply-tools.json');
|
|
11
|
+
const SKILL_VERSION = '4.2.1';
|
|
11
12
|
const CLI_USER_AGENT = `trackly-cli/${PACKAGE_VERSION}`;
|
|
12
13
|
const MCP_USER_AGENT = `trackly-mcp/${PACKAGE_VERSION}`;
|
|
13
14
|
|
|
14
15
|
const SKILL_NAME = 'trackly-apply';
|
|
15
16
|
const SKILL_MAJOR = Number(SKILL_VERSION.split('.')[0]);
|
|
16
|
-
const MIN_APPLY_PROTOCOL_VERSION = '3.1
|
|
17
|
+
const MIN_APPLY_PROTOCOL_VERSION = '3.3.1';
|
|
17
18
|
const CACHE_TTL_MS = 2 * 60 * 60 * 1000;
|
|
18
19
|
const MANAGED_FILE = '.trackly-managed.json';
|
|
20
|
+
const SKILL_MANIFEST_VERSION = 1;
|
|
19
21
|
const RESUME_PROOF_FILE = '.trackly-resume-proof.json';
|
|
20
22
|
const RESUME_PROOF_KEY_BYTES = 32;
|
|
21
23
|
|
|
@@ -52,6 +54,43 @@ function copyDirectory(source, destination) {
|
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
function skillContentManifest(root) {
|
|
58
|
+
const files = [];
|
|
59
|
+
|
|
60
|
+
function collect(directory, relativeDirectory = '') {
|
|
61
|
+
const names = fs.readdirSync(directory).sort();
|
|
62
|
+
for (const name of names) {
|
|
63
|
+
if (!relativeDirectory && name === MANAGED_FILE) continue;
|
|
64
|
+
const absolutePath = path.join(directory, name);
|
|
65
|
+
const relativePath = relativeDirectory ? `${relativeDirectory}/${name}` : name;
|
|
66
|
+
const stat = fs.lstatSync(absolutePath);
|
|
67
|
+
if (stat.isDirectory()) {
|
|
68
|
+
collect(absolutePath, relativePath);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!stat.isFile()) {
|
|
72
|
+
throw new Error(`Trackly Apply skill contains an unsupported filesystem entry: ${relativePath}`);
|
|
73
|
+
}
|
|
74
|
+
const content = fs.readFileSync(absolutePath);
|
|
75
|
+
files.push({
|
|
76
|
+
path: relativePath,
|
|
77
|
+
sizeBytes: content.length,
|
|
78
|
+
sha256: crypto.createHash('sha256').update(content).digest('hex'),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
collect(root);
|
|
84
|
+
const digest = crypto.createHash('sha256')
|
|
85
|
+
.update(JSON.stringify({ version: SKILL_MANIFEST_VERSION, files }))
|
|
86
|
+
.digest('hex');
|
|
87
|
+
return {
|
|
88
|
+
version: SKILL_MANIFEST_VERSION,
|
|
89
|
+
digest,
|
|
90
|
+
fileCount: files.length,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
55
94
|
function managedMetadata(extra = {}) {
|
|
56
95
|
return JSON.stringify({ managedBy: 'trackly-cli', skill: SKILL_NAME, skillVersion: SKILL_VERSION, ...extra }, null, 2);
|
|
57
96
|
}
|
|
@@ -80,7 +119,12 @@ function installCanonicalSkill() {
|
|
|
80
119
|
const staging = `${destination}.staging.${process.pid}.${crypto.randomBytes(4).toString('hex')}`;
|
|
81
120
|
try {
|
|
82
121
|
copyDirectory(source, staging);
|
|
83
|
-
|
|
122
|
+
const manifest = skillContentManifest(staging);
|
|
123
|
+
fs.writeFileSync(path.join(staging, MANAGED_FILE), managedMetadata({
|
|
124
|
+
skillManifestVersion: manifest.version,
|
|
125
|
+
skillContentDigest: manifest.digest,
|
|
126
|
+
skillFileCount: manifest.fileCount,
|
|
127
|
+
}), { mode: 0o600 });
|
|
84
128
|
} catch (error) {
|
|
85
129
|
if (fs.existsSync(staging)) fs.rmSync(staging, { recursive: true, force: true });
|
|
86
130
|
throw error;
|
|
@@ -157,7 +201,13 @@ function linkClientSkill(client, canonical) {
|
|
|
157
201
|
const staging = `${target}.staging.${process.pid}.${crypto.randomBytes(4).toString('hex')}`;
|
|
158
202
|
try {
|
|
159
203
|
copyDirectory(canonical, staging);
|
|
160
|
-
|
|
204
|
+
const manifest = skillContentManifest(staging);
|
|
205
|
+
fs.writeFileSync(path.join(staging, MANAGED_FILE), managedMetadata({
|
|
206
|
+
copiedFrom: canonical,
|
|
207
|
+
skillManifestVersion: manifest.version,
|
|
208
|
+
skillContentDigest: manifest.digest,
|
|
209
|
+
skillFileCount: manifest.fileCount,
|
|
210
|
+
}), { mode: 0o600 });
|
|
161
211
|
fs.renameSync(staging, target);
|
|
162
212
|
if (backup) fs.rmSync(backup, { recursive: true, force: true });
|
|
163
213
|
return { client, target, method: 'copy', status: 'installed' };
|
|
@@ -281,10 +331,47 @@ function inspectClient(client) {
|
|
|
281
331
|
const target = clientSkillDir(client);
|
|
282
332
|
const executable = commandExists(client === 'codex' ? 'codex' : 'claude');
|
|
283
333
|
const metadata = managedSkillMetadata(target);
|
|
284
|
-
|
|
334
|
+
let expectedManifest = null;
|
|
335
|
+
let installedManifest = null;
|
|
336
|
+
try {
|
|
337
|
+
expectedManifest = skillContentManifest(bundledSkillDir());
|
|
338
|
+
installedManifest = skillContentManifest(target);
|
|
339
|
+
} catch (_) {}
|
|
340
|
+
const hasRecordedManifest = metadata?.skillManifestVersion === SKILL_MANIFEST_VERSION
|
|
341
|
+
&& typeof metadata?.skillContentDigest === 'string'
|
|
342
|
+
&& Number.isInteger(metadata?.skillFileCount);
|
|
343
|
+
const contentMatches = hasRecordedManifest
|
|
344
|
+
&& expectedManifest !== null
|
|
345
|
+
&& installedManifest !== null
|
|
346
|
+
&& metadata.skillContentDigest === expectedManifest.digest
|
|
347
|
+
&& metadata.skillContentDigest === installedManifest.digest
|
|
348
|
+
&& metadata.skillFileCount === expectedManifest.fileCount
|
|
349
|
+
&& metadata.skillFileCount === installedManifest.fileCount;
|
|
350
|
+
const skillIntegrity = contentMatches
|
|
351
|
+
? 'verified'
|
|
352
|
+
: metadata && !hasRecordedManifest
|
|
353
|
+
? 'manifest_missing'
|
|
354
|
+
: metadata
|
|
355
|
+
? 'content_mismatch'
|
|
356
|
+
: 'not_managed';
|
|
357
|
+
const installed = fs.existsSync(path.join(target, 'SKILL.md'))
|
|
358
|
+
&& metadata?.skillVersion === SKILL_VERSION
|
|
359
|
+
&& skillIntegrity === 'verified';
|
|
285
360
|
const mcpRegistration = mcpRegistrationState(client);
|
|
286
361
|
const mcpRegistered = mcpRegistration === 'current';
|
|
287
|
-
return {
|
|
362
|
+
return {
|
|
363
|
+
client,
|
|
364
|
+
executable,
|
|
365
|
+
installed,
|
|
366
|
+
installedSkillVersion: metadata?.skillVersion || null,
|
|
367
|
+
skillIntegrity,
|
|
368
|
+
expectedSkillContentDigest: expectedManifest?.digest || null,
|
|
369
|
+
installedSkillContentDigest: installedManifest?.digest || null,
|
|
370
|
+
recordedSkillContentDigest: metadata?.skillContentDigest || null,
|
|
371
|
+
mcpRegistered,
|
|
372
|
+
mcpRegistration,
|
|
373
|
+
target,
|
|
374
|
+
};
|
|
288
375
|
}
|
|
289
376
|
|
|
290
377
|
function cacheDir() {
|
|
@@ -368,11 +455,21 @@ function resumeCacheName(fileName, contentType, now = Date.now(), nonce = crypto
|
|
|
368
455
|
return path.join(`${now}-${nonce}`, safeResumeName(fileName, contentType));
|
|
369
456
|
}
|
|
370
457
|
|
|
371
|
-
function resumeConfirmation(
|
|
458
|
+
function resumeConfirmation(
|
|
459
|
+
fileName,
|
|
460
|
+
sha256,
|
|
461
|
+
sizeBytes,
|
|
462
|
+
exactLocalPath,
|
|
463
|
+
runId,
|
|
464
|
+
expiresAt,
|
|
465
|
+
confirmationId,
|
|
466
|
+
resumeId = null,
|
|
467
|
+
) {
|
|
372
468
|
return {
|
|
373
469
|
required: true,
|
|
374
470
|
source: 'trackly_default_resume',
|
|
375
471
|
runId,
|
|
472
|
+
...(resumeId == null ? {} : { resumeId }),
|
|
376
473
|
confirmationId,
|
|
377
474
|
fileName,
|
|
378
475
|
sha256,
|
|
@@ -450,6 +547,10 @@ function verifyResumeProofSignature(manifest) {
|
|
|
450
547
|
function materializeResume(download, options = {}) {
|
|
451
548
|
const runId = Number(options.runId);
|
|
452
549
|
if (!Number.isInteger(runId) || runId < 1) throw new Error('A valid Trackly Apply run ID is required before preparing a resume.');
|
|
550
|
+
const resumeId = download.resumeId == null ? null : Number(download.resumeId);
|
|
551
|
+
if (resumeId !== null && (!Number.isInteger(resumeId) || resumeId < 1)) {
|
|
552
|
+
throw new Error('Trackly returned an invalid default resume identity.');
|
|
553
|
+
}
|
|
453
554
|
const now = options.now ?? Date.now();
|
|
454
555
|
const nonce = options.nonce ?? crypto.randomBytes(4).toString('hex');
|
|
455
556
|
const confirmationId = options.confirmationId ?? crypto.randomUUID();
|
|
@@ -471,11 +572,13 @@ function materializeResume(download, options = {}) {
|
|
|
471
572
|
runId,
|
|
472
573
|
expiresAt,
|
|
473
574
|
confirmationId,
|
|
575
|
+
resumeId,
|
|
474
576
|
);
|
|
475
577
|
const manifestPath = path.join(path.dirname(destination), RESUME_PROOF_FILE);
|
|
476
578
|
try {
|
|
477
579
|
const manifest = signedResumeProof({
|
|
478
580
|
runId,
|
|
581
|
+
resumeId,
|
|
479
582
|
confirmationId,
|
|
480
583
|
exactLocalPath: destination,
|
|
481
584
|
fileName,
|
|
@@ -494,6 +597,7 @@ function materializeResume(download, options = {}) {
|
|
|
494
597
|
}
|
|
495
598
|
return {
|
|
496
599
|
path: destination,
|
|
600
|
+
resumeId,
|
|
497
601
|
fileName,
|
|
498
602
|
contentType: validated.contentType,
|
|
499
603
|
sha256: validated.digest,
|
|
@@ -542,6 +646,11 @@ function verifyPreparedResume(proof, now = Date.now()) {
|
|
|
542
646
|
}
|
|
543
647
|
|
|
544
648
|
const bindingMatches = manifest.runId === runId
|
|
649
|
+
&& (
|
|
650
|
+
proof.resumeId == null
|
|
651
|
+
? manifest.resumeId == null
|
|
652
|
+
: manifest.resumeId === Number(proof.resumeId)
|
|
653
|
+
)
|
|
545
654
|
&& manifest.confirmationId === proof.confirmationId
|
|
546
655
|
&& manifest.exactLocalPath === expectedPath
|
|
547
656
|
&& manifest.fileName === path.basename(expectedPath)
|
|
@@ -570,6 +679,7 @@ function verifyPreparedResume(proof, now = Date.now()) {
|
|
|
570
679
|
return {
|
|
571
680
|
verified: true,
|
|
572
681
|
runId,
|
|
682
|
+
resumeId: manifest.resumeId ?? null,
|
|
573
683
|
confirmationId: proof.confirmationId,
|
|
574
684
|
exactLocalPath: expectedPath,
|
|
575
685
|
sha256,
|
|
@@ -621,6 +731,9 @@ async function prepareResume(runId, browserSurface, browserBindingHash, userAgen
|
|
|
621
731
|
if (Number(download.applyRunId) !== normalizedRunId) {
|
|
622
732
|
throw new Error('Trackly did not confirm that the resume belongs to the active Apply run.');
|
|
623
733
|
}
|
|
734
|
+
if (!Number.isInteger(Number(download.resumeId)) || Number(download.resumeId) < 1) {
|
|
735
|
+
throw new Error('Trackly did not confirm the default resume identity.');
|
|
736
|
+
}
|
|
624
737
|
return materializeResume(download, { runId: normalizedRunId });
|
|
625
738
|
} finally {
|
|
626
739
|
download.buffer.fill(0);
|
|
@@ -631,7 +744,12 @@ function protocolAtLeast(version, minimum = MIN_APPLY_PROTOCOL_VERSION) {
|
|
|
631
744
|
const parse = (value) => String(value || '').split('.').map((part) => Number(part));
|
|
632
745
|
const actual = parse(version);
|
|
633
746
|
const required = parse(minimum);
|
|
634
|
-
if (
|
|
747
|
+
if (
|
|
748
|
+
actual.length < 2
|
|
749
|
+
|| required.length < 2
|
|
750
|
+
|| actual.some((part) => !Number.isInteger(part) || part < 0)
|
|
751
|
+
|| required.some((part) => !Number.isInteger(part) || part < 0)
|
|
752
|
+
) return false;
|
|
635
753
|
for (let index = 0; index < Math.max(actual.length, required.length); index++) {
|
|
636
754
|
const left = actual[index] || 0;
|
|
637
755
|
const right = required[index] || 0;
|
|
@@ -640,6 +758,47 @@ function protocolAtLeast(version, minimum = MIN_APPLY_PROTOCOL_VERSION) {
|
|
|
640
758
|
return true;
|
|
641
759
|
}
|
|
642
760
|
|
|
761
|
+
function evaluateApplyCompatibility(protocol, clients) {
|
|
762
|
+
const compatibleSkillMajor = Number(protocol?.compatibleSkillMajor);
|
|
763
|
+
const minimumSkillVersion = typeof protocol?.compatibleSkillMinimumVersion === 'string'
|
|
764
|
+
? protocol.compatibleSkillMinimumVersion
|
|
765
|
+
: null;
|
|
766
|
+
const minimumCliVersion = typeof protocol?.compatibleCliMinimumVersion === 'string'
|
|
767
|
+
? protocol.compatibleCliMinimumVersion
|
|
768
|
+
: null;
|
|
769
|
+
const mcpContractCompatible = protocol?.mcpContractVersion === MCP_CONTRACT_VERSION;
|
|
770
|
+
const cliMinimumSatisfied = minimumCliVersion
|
|
771
|
+
? protocolAtLeast(PACKAGE_VERSION, minimumCliVersion)
|
|
772
|
+
: false;
|
|
773
|
+
const protocolCompatible = compatibleSkillMajor === SKILL_MAJOR
|
|
774
|
+
&& protocolAtLeast(protocol?.version, MIN_APPLY_PROTOCOL_VERSION);
|
|
775
|
+
const compatibleClientSkills = minimumSkillVersion
|
|
776
|
+
? clients
|
|
777
|
+
.filter((client) => {
|
|
778
|
+
const installedMajor = Number(String(client.installedSkillVersion || '').split('.')[0]);
|
|
779
|
+
return client.installed === true
|
|
780
|
+
&& installedMajor === compatibleSkillMajor
|
|
781
|
+
&& protocolAtLeast(client.installedSkillVersion, minimumSkillVersion);
|
|
782
|
+
})
|
|
783
|
+
.map((client) => client.client)
|
|
784
|
+
: [];
|
|
785
|
+
const skillMinimumSatisfied = compatibleClientSkills.length > 0;
|
|
786
|
+
return {
|
|
787
|
+
protocolCompatible,
|
|
788
|
+
mcpContractVersion: MCP_CONTRACT_VERSION,
|
|
789
|
+
mcpContractCompatible,
|
|
790
|
+
minimumCliVersion,
|
|
791
|
+
cliMinimumSatisfied,
|
|
792
|
+
minimumSkillVersion,
|
|
793
|
+
compatibleClientSkills,
|
|
794
|
+
skillMinimumSatisfied,
|
|
795
|
+
compatible: protocolCompatible
|
|
796
|
+
&& mcpContractCompatible
|
|
797
|
+
&& cliMinimumSatisfied
|
|
798
|
+
&& skillMinimumSatisfied,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
643
802
|
function liveBrowserReady(browserControl) {
|
|
644
803
|
return (browserControl?.codex === true && browserControl?.codexComputerUse === true)
|
|
645
804
|
|| browserControl?.claude === true;
|
|
@@ -654,13 +813,38 @@ async function doctorAgent() {
|
|
|
654
813
|
codexComputerUse,
|
|
655
814
|
claude: null,
|
|
656
815
|
};
|
|
816
|
+
const tabInventory = {
|
|
817
|
+
controllerOwnedTabs: 'runtime_verification_required',
|
|
818
|
+
userOwnedTabs: 'runtime_verification_required',
|
|
819
|
+
unionReconciliation: 'runtime_verification_required',
|
|
820
|
+
detectedSurfaces: [],
|
|
821
|
+
unionInventoryDetected: false,
|
|
822
|
+
requiredForClosedClaim: true,
|
|
823
|
+
note: 'Installed plugins do not prove controller-owned and user-owned tab union inventory. The active browser client must verify both inventories before claiming a tab is closed.',
|
|
824
|
+
};
|
|
825
|
+
const clients = ['codex', 'claude'].map(inspectClient);
|
|
826
|
+
const skillIntegrityFailures = clients
|
|
827
|
+
.filter((client) => (
|
|
828
|
+
client.installedSkillVersion === SKILL_VERSION
|
|
829
|
+
&& client.skillIntegrity !== 'verified'
|
|
830
|
+
))
|
|
831
|
+
.map((client) => ({ client: client.client, integrity: client.skillIntegrity }));
|
|
657
832
|
const report = {
|
|
833
|
+
cliVersion: PACKAGE_VERSION,
|
|
834
|
+
mcpContractVersion: MCP_CONTRACT_VERSION,
|
|
658
835
|
skillVersion: SKILL_VERSION,
|
|
659
836
|
authenticated: hasAuth(),
|
|
660
|
-
clients
|
|
837
|
+
clients,
|
|
838
|
+
skillPackIntegrity: {
|
|
839
|
+
ok: skillIntegrityFailures.length === 0,
|
|
840
|
+
expectedDigest: clients.find((client) => client.expectedSkillContentDigest)
|
|
841
|
+
?.expectedSkillContentDigest || null,
|
|
842
|
+
failures: skillIntegrityFailures,
|
|
843
|
+
},
|
|
661
844
|
browserControl: {
|
|
662
845
|
...browserControl,
|
|
663
846
|
semanticReady: liveBrowserReady(browserControl),
|
|
847
|
+
tabInventory,
|
|
664
848
|
note: 'A live run requires both semantic browser control and computer use. Claude capability must be verified by the active client before the run starts.',
|
|
665
849
|
},
|
|
666
850
|
cache,
|
|
@@ -682,8 +866,8 @@ async function doctorAgent() {
|
|
|
682
866
|
completeness: profile.profile?.completeness,
|
|
683
867
|
hasDefaultResume: Boolean(profile.profile?.defaultResume),
|
|
684
868
|
};
|
|
685
|
-
report.
|
|
686
|
-
|
|
869
|
+
report.compatibility = evaluateApplyCompatibility(report.protocol, report.clients);
|
|
870
|
+
report.compatible = report.compatibility.compatible;
|
|
687
871
|
if (report.profile.hasDefaultResume) {
|
|
688
872
|
report.resume = {
|
|
689
873
|
available: true,
|
|
@@ -697,6 +881,7 @@ async function doctorAgent() {
|
|
|
697
881
|
report.ok = report.authenticated
|
|
698
882
|
&& report.compatible
|
|
699
883
|
&& report.browserControl.semanticReady
|
|
884
|
+
&& report.skillPackIntegrity.ok
|
|
700
885
|
&& report.clients.some((client) => client.executable && client.installed && client.mcpRegistered)
|
|
701
886
|
&& Boolean(report.profile?.hasDefaultResume)
|
|
702
887
|
&& report.resume.available
|
|
@@ -724,6 +909,7 @@ module.exports = {
|
|
|
724
909
|
cleanResumeCache,
|
|
725
910
|
clientSkillDir,
|
|
726
911
|
doctorAgent,
|
|
912
|
+
evaluateApplyCompatibility,
|
|
727
913
|
inspectClient,
|
|
728
914
|
liveBrowserReady,
|
|
729
915
|
materializeResume,
|
package/lib/client.js
CHANGED
|
@@ -18,6 +18,22 @@ const MAINTENANCE_APPLY_GUIDANCE =
|
|
|
18
18
|
'Do not blindly retry an Apply mutation. Wait for the advertised maintenance window, ' +
|
|
19
19
|
'refetch the Apply protocol and application profile, then resume the existing agent_browser run. ' +
|
|
20
20
|
'Never create a duplicate run or click Submit.';
|
|
21
|
+
const TRACKLY_ACCESS_GUIDANCE =
|
|
22
|
+
'Trackly is in a limited rollout. Existing members can sign in; new members need a private invite. ' +
|
|
23
|
+
'Request access at https://usetrackly.app/early-access.';
|
|
24
|
+
const TRACKLY_ACCESS_PENDING_GUIDANCE =
|
|
25
|
+
'Your Trackly access request is already pending. No new request is needed.';
|
|
26
|
+
const TRACKLY_ACCESS_UNAVAILABLE_GUIDANCE =
|
|
27
|
+
'Trackly access could not be verified right now. Please try again.';
|
|
28
|
+
const TRACKLY_ACCESS_CODES = new Set([
|
|
29
|
+
'INVITATION_REQUIRED',
|
|
30
|
+
'INVITATION_INVALID',
|
|
31
|
+
'INVITATION_REDEEMED',
|
|
32
|
+
'SIGNUP_INTENT_EXPIRED',
|
|
33
|
+
'ACCESS_BATCH_FULL',
|
|
34
|
+
'ACCESS_CHECK_UNAVAILABLE',
|
|
35
|
+
'ALREADY_REQUESTED',
|
|
36
|
+
]);
|
|
21
37
|
|
|
22
38
|
class TracklyConfigError extends Error {
|
|
23
39
|
constructor(message) {
|
|
@@ -243,6 +259,27 @@ function nestedRecord(value) {
|
|
|
243
259
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
244
260
|
}
|
|
245
261
|
|
|
262
|
+
function createTracklyAccessError(json, status) {
|
|
263
|
+
const record = nestedRecord(json);
|
|
264
|
+
const code = cleanText(record.code);
|
|
265
|
+
if (!code || !TRACKLY_ACCESS_CODES.has(code.toUpperCase())) return null;
|
|
266
|
+
const normalizedCode = code.toUpperCase();
|
|
267
|
+
const retryable = normalizedCode === 'ACCESS_CHECK_UNAVAILABLE';
|
|
268
|
+
const message = retryable
|
|
269
|
+
? TRACKLY_ACCESS_UNAVAILABLE_GUIDANCE
|
|
270
|
+
: normalizedCode === 'ALREADY_REQUESTED'
|
|
271
|
+
? TRACKLY_ACCESS_PENDING_GUIDANCE
|
|
272
|
+
: TRACKLY_ACCESS_GUIDANCE;
|
|
273
|
+
return {
|
|
274
|
+
...record,
|
|
275
|
+
status: status ?? (retryable ? 503 : 403),
|
|
276
|
+
code: normalizedCode,
|
|
277
|
+
error: message,
|
|
278
|
+
message,
|
|
279
|
+
retryable,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
246
283
|
// REST maintenance responses and hosted MCP JSON-RPC errors intentionally use
|
|
247
284
|
// different envelopes. Collapse both into the same source view so every CLI
|
|
248
285
|
// surface preserves equivalent code/retry/ETA/request context.
|
|
@@ -472,7 +509,15 @@ async function _doRefresh() {
|
|
|
472
509
|
return null;
|
|
473
510
|
}
|
|
474
511
|
|
|
475
|
-
function apiRequest(
|
|
512
|
+
function apiRequest(
|
|
513
|
+
method,
|
|
514
|
+
endpoint,
|
|
515
|
+
data = null,
|
|
516
|
+
skipAuth = false,
|
|
517
|
+
_isRetry = false,
|
|
518
|
+
userAgent = null,
|
|
519
|
+
additionalHeaders = null
|
|
520
|
+
) {
|
|
476
521
|
return new Promise((resolve, reject) => {
|
|
477
522
|
let url;
|
|
478
523
|
try {
|
|
@@ -485,6 +530,27 @@ function apiRequest(method, endpoint, data = null, skipAuth = false, _isRetry =
|
|
|
485
530
|
const isHttps = url.protocol === 'https:';
|
|
486
531
|
const lib = isHttps ? https : http;
|
|
487
532
|
const headers = getRequestHeaders(skipAuth, userAgent);
|
|
533
|
+
if (additionalHeaders !== null) {
|
|
534
|
+
const entries = (
|
|
535
|
+
typeof additionalHeaders === 'object'
|
|
536
|
+
&& additionalHeaders !== null
|
|
537
|
+
&& !Array.isArray(additionalHeaders)
|
|
538
|
+
&& Object.getPrototypeOf(additionalHeaders) === Object.prototype
|
|
539
|
+
)
|
|
540
|
+
? Object.entries(additionalHeaders)
|
|
541
|
+
: [];
|
|
542
|
+
const idempotencyEntry = entries.find(([name]) => name.toLowerCase() === 'idempotency-key');
|
|
543
|
+
if (
|
|
544
|
+
entries.length !== 1
|
|
545
|
+
|| !idempotencyEntry
|
|
546
|
+
|| typeof idempotencyEntry[1] !== 'string'
|
|
547
|
+
|| !/^[\x20-\x7e]{16,200}$/.test(idempotencyEntry[1])
|
|
548
|
+
) {
|
|
549
|
+
reject(new Error('Only a valid Idempotency-Key additional header is permitted.'));
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
headers['Idempotency-Key'] = idempotencyEntry[1];
|
|
553
|
+
}
|
|
488
554
|
const authMethod = getAuthMethod();
|
|
489
555
|
const timeoutMs = getRequestTimeoutMs();
|
|
490
556
|
const hasSensitivePayload = Boolean(data && typeof data === 'object' && 'refreshToken' in data);
|
|
@@ -567,7 +633,15 @@ function apiRequest(method, endpoint, data = null, skipAuth = false, _isRetry =
|
|
|
567
633
|
}
|
|
568
634
|
if (newToken) {
|
|
569
635
|
try {
|
|
570
|
-
const retried = await apiRequest(
|
|
636
|
+
const retried = await apiRequest(
|
|
637
|
+
method,
|
|
638
|
+
endpoint,
|
|
639
|
+
data,
|
|
640
|
+
false,
|
|
641
|
+
true,
|
|
642
|
+
userAgent,
|
|
643
|
+
additionalHeaders
|
|
644
|
+
);
|
|
571
645
|
return resolve(retried);
|
|
572
646
|
} catch (e) { return reject(e); }
|
|
573
647
|
}
|
|
@@ -579,6 +653,8 @@ function apiRequest(method, endpoint, data = null, skipAuth = false, _isRetry =
|
|
|
579
653
|
headers: res.headers,
|
|
580
654
|
});
|
|
581
655
|
if (maintenanceError) return reject(maintenanceError);
|
|
656
|
+
const accessError = createTracklyAccessError(json, res.statusCode);
|
|
657
|
+
if (accessError) return reject(accessError);
|
|
582
658
|
// Spread `json` FIRST so the HTTP statusCode always wins. A backend that returns
|
|
583
659
|
// `{"status": "error"}` in the body would otherwise clobber the numeric status with
|
|
584
660
|
// a string, breaking downstream branches like `typeof e.status === 'number'` in
|
|
@@ -676,6 +752,11 @@ function downloadFile(endpoint, userAgent = null, _isRetry = false) {
|
|
|
676
752
|
reject(maintenanceError);
|
|
677
753
|
return;
|
|
678
754
|
}
|
|
755
|
+
const accessError = createTracklyAccessError(body, res.statusCode);
|
|
756
|
+
if (accessError) {
|
|
757
|
+
reject(accessError);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
679
760
|
reject({ ...body, status: res.statusCode, message: body.error || `HTTP ${res.statusCode}` });
|
|
680
761
|
return;
|
|
681
762
|
}
|
|
@@ -686,6 +767,7 @@ function downloadFile(endpoint, userAgent = null, _isRetry = false) {
|
|
|
686
767
|
fileName: contentDispositionFileName(disposition),
|
|
687
768
|
sha256: res.headers['x-trackly-file-sha256'] || null,
|
|
688
769
|
applyRunId: res.headers['x-trackly-apply-run-id'] || null,
|
|
770
|
+
resumeId: res.headers['x-trackly-resume-id'] || null,
|
|
689
771
|
});
|
|
690
772
|
});
|
|
691
773
|
});
|
|
@@ -720,6 +802,7 @@ module.exports = {
|
|
|
720
802
|
TracklyConfigError,
|
|
721
803
|
MAINTENANCE_APPLY_GUIDANCE,
|
|
722
804
|
MAINTENANCE_CODE,
|
|
805
|
+
TRACKLY_ACCESS_GUIDANCE,
|
|
723
806
|
apiRequest,
|
|
724
807
|
downloadFile,
|
|
725
808
|
clearConfig,
|
|
@@ -738,6 +821,7 @@ module.exports = {
|
|
|
738
821
|
hasAuth,
|
|
739
822
|
loadConfig,
|
|
740
823
|
createMaintenanceError,
|
|
824
|
+
createTracklyAccessError,
|
|
741
825
|
isMaintenanceError,
|
|
742
826
|
maintenanceOutput,
|
|
743
827
|
normalizeMaintenance,
|