job-application-agent 3.1.1 → 3.2.0
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 +11 -2
- package/job-application-agent/SKILL.md +29 -19
- package/job-application-agent/references/ANALYTICS.md +4 -0
- package/job-application-agent/references/RUNS.md +2 -1
- package/job-application-agent/references/SCHEMAS.md +8 -3
- package/job-application-agent/references/SOURCES.json +156 -0
- package/job-application-agent/references/SOURCES.md +73 -0
- package/job-application-agent/scripts/job-application.mjs +286 -23
- package/job-application-agent/scripts/source-community-client.mjs +338 -0
- package/job-application-agent/scripts/source-community-schema.mjs +320 -0
- package/job-application-agent/scripts/telemetry-client.mjs +3 -1
- package/job-application-agent/scripts/version.mjs +1 -0
- package/job-application-agent/tests/job-application.test.mjs +12 -9
- package/job-application-agent/tests/privacy-audit.test.mjs +39 -0
- package/job-application-agent/tests/source-community-client.test.mjs +351 -0
- package/job-application-agent/tests/source-community-schema.test.mjs +270 -0
- package/job-application-agent/tests/telemetry-client.test.mjs +1 -1
- package/job-application-agent/tests/version.test.mjs +12 -0
- package/job-application-agent/tests/workflow-state.test.mjs +506 -7
- package/package.json +7 -3
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
communityJobId,
|
|
6
|
+
communitySourceId,
|
|
7
|
+
createCommunityJobContributionEnvelope,
|
|
8
|
+
isRepeatableCommunitySourceRoute,
|
|
9
|
+
normalizeCommunityJob,
|
|
10
|
+
normalizeCommunitySource,
|
|
11
|
+
validateCommunityJobContributionEnvelope,
|
|
12
|
+
validateCommunityJobList,
|
|
13
|
+
} from '../scripts/source-community-schema.mjs';
|
|
14
|
+
|
|
15
|
+
const source = {
|
|
16
|
+
name: 'Example Jobs',
|
|
17
|
+
baseUrl: 'https://example.com/',
|
|
18
|
+
kind: 'job-board',
|
|
19
|
+
regions: ['global'],
|
|
20
|
+
roleFamilies: ['engineering'],
|
|
21
|
+
requiresSession: false,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const job = {
|
|
25
|
+
url: 'https://jobs.ashbyhq.com/example/12345678-1234-4123-8123-123456789abc?utm_source=private#apply',
|
|
26
|
+
company: 'Example',
|
|
27
|
+
role: 'Senior Product Engineer',
|
|
28
|
+
applicationChannel: 'ashby',
|
|
29
|
+
discoverySource: 'job-board',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
test('normalizes an applied job and derives its reusable provider without referral data', () => {
|
|
33
|
+
const normalized = normalizeCommunityJob(job);
|
|
34
|
+
|
|
35
|
+
assert.deepEqual(normalized, {
|
|
36
|
+
url: 'https://jobs.ashbyhq.com/example/12345678-1234-4123-8123-123456789abc',
|
|
37
|
+
company: 'Example',
|
|
38
|
+
role: 'Senior Product Engineer',
|
|
39
|
+
applicationChannel: 'ashby',
|
|
40
|
+
discoverySource: 'job-board',
|
|
41
|
+
providerUrl: 'https://jobs.ashbyhq.com/example',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('accepts public job detail identifiers but rejects private, personal, and credential-bearing routes', () => {
|
|
46
|
+
const accepted = [
|
|
47
|
+
'https://job-boards.greenhouse.io/example/jobs/1234567',
|
|
48
|
+
'https://jobs.lever.co/example/12345678-1234-4123-8123-123456789abc',
|
|
49
|
+
'https://jobs.ashbyhq.com/example/12345678-1234-4123-8123-123456789abc',
|
|
50
|
+
'https://company.example/careers/senior-product-engineer',
|
|
51
|
+
];
|
|
52
|
+
for (const url of accepted) assert.equal(normalizeCommunityJob({ ...job, url }).url, url);
|
|
53
|
+
|
|
54
|
+
const rejected = [
|
|
55
|
+
'http://localhost/jobs/123',
|
|
56
|
+
'https://linkedin.com/in/some-person',
|
|
57
|
+
'https://company.example/candidate/9876543210',
|
|
58
|
+
'https://company.example/referral/12345678-1234-4123-8123-123456789abc',
|
|
59
|
+
'https://candidate-14155550100.example.org/jobs/123',
|
|
60
|
+
'https://company.example/jobs/+1-415-555-0100',
|
|
61
|
+
'https://company.example/jobs/access-token=abcdefghijklmnop',
|
|
62
|
+
'https://user:password@company.example/jobs/123',
|
|
63
|
+
];
|
|
64
|
+
for (const url of rejected) assert.throws(() => normalizeCommunityJob({ ...job, url }), /public HTTPS|personal|credential/i, url);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('preserves only stable query job identifiers while removing referral data', async () => {
|
|
68
|
+
const first = normalizeCommunityJob({
|
|
69
|
+
...job,
|
|
70
|
+
url: 'https://company.example/viewjob?jobId=111&utm_source=private&ref=candidate@example.com',
|
|
71
|
+
});
|
|
72
|
+
const second = normalizeCommunityJob({
|
|
73
|
+
...job,
|
|
74
|
+
url: 'https://company.example/viewjob?jobId=222&utm_source=private',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
assert.equal(first.url, 'https://company.example/viewjob?jobid=111');
|
|
78
|
+
assert.equal(second.url, 'https://company.example/viewjob?jobid=222');
|
|
79
|
+
assert.notEqual(await communityJobId(first), await communityJobId(second));
|
|
80
|
+
assert.equal(first.url.includes('candidate@example.com'), false);
|
|
81
|
+
assert.equal(normalizeCommunityJob({ ...job, url: 'https://boards.example/jobs?gh_jid=7654321&utm_campaign=secret' }).url, 'https://boards.example/jobs?gh_jid=7654321');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('community job IDs deduplicate tracking variants and contribution envelopes validate strictly', async () => {
|
|
85
|
+
const first = await communityJobId(job);
|
|
86
|
+
const second = await communityJobId({ ...job, url: `${job.url.split('?')[0]}?ref=another#details` });
|
|
87
|
+
assert.equal(first, second);
|
|
88
|
+
assert.match(first, /^community-job-[0-9a-f]{16}$/);
|
|
89
|
+
|
|
90
|
+
const envelope = createCommunityJobContributionEnvelope({
|
|
91
|
+
installationId: '12345678-1234-4123-8123-123456789abc',
|
|
92
|
+
token: 'signed-token',
|
|
93
|
+
job,
|
|
94
|
+
skillVersion: '3.2.0',
|
|
95
|
+
});
|
|
96
|
+
assert.deepEqual(validateCommunityJobContributionEnvelope(envelope), envelope);
|
|
97
|
+
assert.throws(() => validateCommunityJobContributionEnvelope({ ...envelope, answers: { private: true } }), /Unknown community job contribution property/i);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('validates paginated public community job responses without accepting extra data', async () => {
|
|
101
|
+
const jobId = await communityJobId(job);
|
|
102
|
+
const response = {
|
|
103
|
+
version: 1,
|
|
104
|
+
jobs: [{
|
|
105
|
+
jobId,
|
|
106
|
+
...normalizeCommunityJob(job),
|
|
107
|
+
firstSeenAt: '2026-08-24T10:00:00.000Z',
|
|
108
|
+
lastSeenAt: '2026-08-24T11:00:00.000Z',
|
|
109
|
+
contributionCount: 2,
|
|
110
|
+
}],
|
|
111
|
+
nextCursor: 'opaque-cursor',
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
assert.deepEqual(validateCommunityJobList(response), response);
|
|
115
|
+
assert.throws(() => validateCommunityJobList({ ...response, installationId: 'private' }), /Unknown community job list property/i);
|
|
116
|
+
assert.throws(() => validateCommunityJobList({ ...response, jobs: [{ ...response.jobs[0], score: 90 }] }), /Unknown community job entry property/i);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('rejects known ATS and network job-detail routes', () => {
|
|
120
|
+
const detailUrls = [
|
|
121
|
+
'https://example.wd5.myworkdayjobs.com/en-US/jobs/job/Bengaluru/Senior-Engineer_R-12345',
|
|
122
|
+
'https://www.linkedin.com/jobs/view/1234567890',
|
|
123
|
+
'https://job-boards.greenhouse.io/example/jobs/1234567',
|
|
124
|
+
'https://jobs.lever.co/example/12345678-1234-4123-8123-123456789abc',
|
|
125
|
+
'https://jobs.ashbyhq.com/example/12345678-1234-4123-8123-123456789abc',
|
|
126
|
+
'https://apply.workable.com/example/j/ABC123DEF4/',
|
|
127
|
+
'https://jobs.smartrecruiters.com/Example/123456789-senior-engineer',
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
for (const baseUrl of detailUrls) {
|
|
131
|
+
assert.equal(isRepeatableCommunitySourceRoute(new URL(baseUrl)), false, baseUrl);
|
|
132
|
+
assert.throws(
|
|
133
|
+
() => normalizeCommunitySource({ ...source, baseUrl }),
|
|
134
|
+
/repeatable discovery surface|identity-like content/i,
|
|
135
|
+
baseUrl,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('accepts roots and recognizable collection, directory, feed, careers, openings, and job-index routes', () => {
|
|
141
|
+
const collectionUrls = [
|
|
142
|
+
'https://example.com/',
|
|
143
|
+
'https://example.com/careers',
|
|
144
|
+
'https://example.com/openings/engineering',
|
|
145
|
+
'https://example.com/jobs/search',
|
|
146
|
+
'https://example.com/job-index',
|
|
147
|
+
'https://example.com/community/directory',
|
|
148
|
+
'https://example.com/hiring/feed.xml',
|
|
149
|
+
'https://example.wd5.myworkdayjobs.com/en-US/jobs',
|
|
150
|
+
'https://www.linkedin.com/jobs/search',
|
|
151
|
+
'https://job-boards.greenhouse.io/example',
|
|
152
|
+
'https://jobs.lever.co/example',
|
|
153
|
+
'https://jobs.ashbyhq.com/example',
|
|
154
|
+
'https://apply.workable.com/example',
|
|
155
|
+
'https://jobs.smartrecruiters.com/Example',
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
for (const baseUrl of collectionUrls) {
|
|
159
|
+
assert.equal(isRepeatableCommunitySourceRoute(new URL(baseUrl)), true, baseUrl);
|
|
160
|
+
assert.equal(normalizeCommunitySource({ ...source, baseUrl }).baseUrl, baseUrl.replace(/\/$/, ''), baseUrl);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('fails closed for unknown non-collection paths and strips collection queries and fragments', () => {
|
|
165
|
+
for (const baseUrl of ['https://example.com/software-engineer', 'https://example.com/jobs/senior-software-engineer']) {
|
|
166
|
+
assert.equal(isRepeatableCommunitySourceRoute(new URL(baseUrl)), false, baseUrl);
|
|
167
|
+
assert.throws(() => normalizeCommunitySource({ ...source, baseUrl }), /repeatable discovery surface/i);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const normalized = normalizeCommunitySource({
|
|
171
|
+
...source,
|
|
172
|
+
baseUrl: 'https://example.com/openings/engineering?email=candidate@example.com&token=secret#jobs',
|
|
173
|
+
});
|
|
174
|
+
assert.equal(normalized.baseUrl, 'https://example.com/openings/engineering');
|
|
175
|
+
assert.equal(JSON.stringify(normalized).includes('candidate@example.com'), false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('rejects identity-like path namespaces even when they contain a collection cue', () => {
|
|
179
|
+
for (const baseUrl of ['https://example.com/users/jane/openings', 'https://example.com/profile/jane/careers', 'https://x.com/jane/jobs']) {
|
|
180
|
+
assert.throws(() => normalizeCommunitySource({ ...source, baseUrl }), /profile or personal|identity-like/i, baseUrl);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('rejects repeatedly encoded identity paths and identity-bearing taxonomy fields', () => {
|
|
185
|
+
assert.throws(
|
|
186
|
+
() => normalizeCommunitySource({ ...source, baseUrl: 'https://example.com/candidate%2540example.com/openings' }),
|
|
187
|
+
/identity-like/i,
|
|
188
|
+
);
|
|
189
|
+
assert.throws(() => normalizeCommunitySource({ ...source, regions: ['candidate@example.com'] }), /identity-like/i);
|
|
190
|
+
assert.throws(() => normalizeCommunitySource({ ...source, roleFamilies: ['+1 415 555 0100'] }), /identity-like/i);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('rejects Unicode and compatibility-form email identities before sharing', () => {
|
|
194
|
+
const privateValues = [
|
|
195
|
+
{ name: 'Jobs curated by josé@example.com' },
|
|
196
|
+
{ regions: ['用户@example.com'] },
|
|
197
|
+
{ regions: ['उपयोगकर्ता@example.com'] },
|
|
198
|
+
{ roleFamilies: ['jose@example.com'] },
|
|
199
|
+
{ baseUrl: 'https://example.com/%E7%94%A8%E6%88%B7%40example.com/openings' },
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
for (const override of privateValues) {
|
|
203
|
+
assert.throws(() => normalizeCommunitySource({ ...source, ...override }), /identity-like/i);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('rejects credential-like opaque path segments before source sharing', () => {
|
|
208
|
+
assert.throws(
|
|
209
|
+
() => normalizeCommunitySource({ ...source, baseUrl: 'https://example.com/feed/AbCdEfGhIjKlMnOpQrStUvWxYz/jobs' }),
|
|
210
|
+
/credential-like/i,
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('rejects JWT, key-value, and prefixed credentials in source paths', () => {
|
|
215
|
+
const credentialUrls = [
|
|
216
|
+
'https://example.com/feed/eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c/jobs',
|
|
217
|
+
'https://example.com/feed/token=AbCdEfGhIjKlMnOpQrStUvWxYz/jobs',
|
|
218
|
+
'https://example.com/feed/api_key.AbCdEfGhIjKlMnOpQrStUvWxYz/jobs',
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
for (const baseUrl of credentialUrls) {
|
|
222
|
+
assert.throws(() => normalizeCommunitySource({ ...source, baseUrl }), /credential-like/i, baseUrl);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('rejects phone identities written with Unicode decimal digits', () => {
|
|
227
|
+
const privateValues = [
|
|
228
|
+
{ name: 'Jobs curated by +٩١ ٩٨٧٦٥ ٤٣٢١٠' },
|
|
229
|
+
{ regions: ['+९१ ९८७६५ ४३२१०'] },
|
|
230
|
+
{ baseUrl: 'https://example.com/%2B%D9%A9%D9%A1%20%D9%A9%D9%A8%D9%A7%D9%A6%D9%A5%20%D9%A4%D9%A3%D9%A2%D9%A1%D9%A0/jobs' },
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
for (const override of privateValues) {
|
|
234
|
+
assert.throws(() => normalizeCommunitySource({ ...source, ...override }), /identity-like/i);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('normalizes compatibility characters before personal-path classification', () => {
|
|
239
|
+
for (const baseUrl of ['https://example.com/profile/candidate/jobs', 'https://example.com/user/candidate/jobs']) {
|
|
240
|
+
assert.throws(() => normalizeCommunitySource({ ...source, baseUrl }), /identity-like/i, baseUrl);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('removes every trailing path separator when canonicalizing source URLs', async () => {
|
|
245
|
+
const canonical = normalizeCommunitySource({ ...source, baseUrl: 'https://example.com/jobs' });
|
|
246
|
+
const redundant = normalizeCommunitySource({ ...source, baseUrl: 'https://example.com/jobs///' });
|
|
247
|
+
|
|
248
|
+
assert.equal(redundant.baseUrl, canonical.baseUrl);
|
|
249
|
+
assert.equal(await communitySourceId(redundant), await communitySourceId(canonical));
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('normalizes trailing DNS root dots before rejecting private hosts', () => {
|
|
253
|
+
for (const baseUrl of ['https://localhost./jobs', 'https://service.local./careers', 'https://service.internal./openings']) {
|
|
254
|
+
assert.throws(() => normalizeCommunitySource({ ...source, baseUrl }), /public internet hostname/i, baseUrl);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('rejects identity-like public hostnames after normalizing a trailing DNS root dot', () => {
|
|
259
|
+
for (const baseUrl of ['https://14155550100.example.org./jobs', 'https://candidate-14155550100.example.org/openings']) {
|
|
260
|
+
assert.throws(() => normalizeCommunitySource({ ...source, baseUrl }), /identity-like/i, baseUrl);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('source IDs normalize scheme and hostname case while preserving path case', async () => {
|
|
265
|
+
const upperHost = await communitySourceId({ ...source, baseUrl: 'HTTPS://EXAMPLE.COM/Jobs' });
|
|
266
|
+
const lowerHost = await communitySourceId({ ...source, baseUrl: 'https://example.com/Jobs' });
|
|
267
|
+
const lowerPath = await communitySourceId({ ...source, baseUrl: 'https://example.com/jobs' });
|
|
268
|
+
assert.equal(upperHost, lowerHost);
|
|
269
|
+
assert.notEqual(upperHost, lowerPath);
|
|
270
|
+
});
|
|
@@ -30,7 +30,7 @@ test('new installations disclose and send the first event immediately', async (t
|
|
|
30
30
|
assert.equal(result.sent, true);
|
|
31
31
|
assert.equal(relay.requests.length, 3);
|
|
32
32
|
assert.equal((await client.beginCommand('search')).installationEventPending, false);
|
|
33
|
-
assert.equal((await stat(join(directory, 'telemetry.json'))).mode & 0o777, 0o600);
|
|
33
|
+
if (process.platform !== 'win32') assert.equal((await stat(join(directory, 'telemetry.json'))).mode & 0o777, 0o600);
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
test('existing installations receive a one-command grace period without backfill', async (t) => {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
|
|
5
|
+
import { SKILL_VERSION as telemetryVersion } from '../scripts/telemetry-client.mjs';
|
|
6
|
+
import { SKILL_VERSION } from '../scripts/version.mjs';
|
|
7
|
+
|
|
8
|
+
test('the packaged skill and telemetry report the npm package version', async () => {
|
|
9
|
+
const manifest = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
10
|
+
assert.equal(SKILL_VERSION, manifest.version);
|
|
11
|
+
assert.equal(telemetryVersion, manifest.version);
|
|
12
|
+
});
|