job-application-agent 3.1.0 → 3.1.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/README.md +88 -128
- package/job-application-agent/SKILL.md +28 -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 +66 -0
- package/job-application-agent/scripts/job-application.mjs +208 -23
- package/job-application-agent/scripts/source-community-client.mjs +254 -0
- package/job-application-agent/scripts/source-community-schema.mjs +180 -0
- package/job-application-agent/tests/job-application.test.mjs +11 -9
- package/job-application-agent/tests/privacy-audit.test.mjs +16 -0
- package/job-application-agent/tests/source-community-client.test.mjs +245 -0
- package/job-application-agent/tests/source-community-schema.test.mjs +166 -0
- package/job-application-agent/tests/telemetry-client.test.mjs +1 -1
- package/job-application-agent/tests/workflow-state.test.mjs +394 -7
- package/package.json +7 -3
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { chmod, mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
|
+
|
|
5
|
+
import { SKILL_VERSION } from './telemetry-client.mjs';
|
|
6
|
+
import { createSourceContributionEnvelope, normalizeCommunitySource, validateCommunitySourceList } from './source-community-schema.mjs';
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_SOURCE_COMMUNITY_ENDPOINT = process.env.JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_URL ?? process.env.JOB_APPLICATION_AGENT_TELEMETRY_URL ?? 'https://job-application-agent-telemetry.varora1406.workers.dev';
|
|
9
|
+
export const SOURCE_SHARING_NOTICE = 'Community source sharing is enabled by default. Repeatable public job boards and hiring feeds are shared anonymously into a pending maintainer-review queue after removing personal and referral data. Run `sources sharing disable` to opt out.\n';
|
|
10
|
+
|
|
11
|
+
const CONFIG_FILE = 'source-sharing.json';
|
|
12
|
+
const CONFIG_LOCK_FILE = '.source-sharing.lock';
|
|
13
|
+
const CONFIG_LOCK_TIMEOUT_MS = 15_000;
|
|
14
|
+
const CONFIG_LOCK_STALE_MS = 60_000;
|
|
15
|
+
|
|
16
|
+
function defaultConfig() {
|
|
17
|
+
return { version: 1, enabled: true, disclosed: false, installationId: null, token: null, tokenExpiresAt: null };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sameCredentialState(left, right) {
|
|
21
|
+
return left.enabled === right.enabled
|
|
22
|
+
&& left.installationId === right.installationId
|
|
23
|
+
&& left.token === right.token
|
|
24
|
+
&& left.tokenExpiresAt === right.tokenExpiresAt;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function processIsAlive(pid) {
|
|
28
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
29
|
+
if (pid === process.pid) return true;
|
|
30
|
+
try {
|
|
31
|
+
process.kill(pid, 0);
|
|
32
|
+
return true;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error.code === 'ESRCH') return false;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function writePrivate(file, value) {
|
|
40
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
41
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
42
|
+
await rename(temporary, file);
|
|
43
|
+
await chmod(file, 0o600);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class SourceCommunityClient {
|
|
47
|
+
constructor({ stateDir, endpoint = DEFAULT_SOURCE_COMMUNITY_ENDPOINT, fetch: fetchFn = globalThis.fetch, stderr = (value) => process.stderr.write(value), now = () => new Date(), timeoutMs = Number(process.env.JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_TIMEOUT_MS ?? 3000) }) {
|
|
48
|
+
this.stateDir = stateDir;
|
|
49
|
+
this.endpoint = endpoint.replace(/\/$/, '');
|
|
50
|
+
this.fetch = fetchFn;
|
|
51
|
+
this.stderr = stderr;
|
|
52
|
+
this.now = now;
|
|
53
|
+
this.timeoutMs = timeoutMs;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get configPath() { return join(this.stateDir, CONFIG_FILE); }
|
|
57
|
+
get configLockPath() { return join(this.stateDir, CONFIG_LOCK_FILE); }
|
|
58
|
+
|
|
59
|
+
async ensureDirectory() {
|
|
60
|
+
await mkdir(this.stateDir, { recursive: true, mode: 0o700 });
|
|
61
|
+
await chmod(this.stateDir, 0o700);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async readConfig() {
|
|
65
|
+
try {
|
|
66
|
+
const value = JSON.parse(await readFile(this.configPath, 'utf8'));
|
|
67
|
+
return { version: 1, enabled: value.enabled !== false, disclosed: value.disclosed === true, installationId: value.installationId ?? null, token: value.token ?? null, tokenExpiresAt: value.tokenExpiresAt ?? null };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error.code === 'ENOENT') return null;
|
|
70
|
+
return { version: 1, enabled: false, disclosed: true, installationId: null, token: null, tokenExpiresAt: null };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async saveConfigUnlocked(config) {
|
|
75
|
+
await this.ensureDirectory();
|
|
76
|
+
await writePrivate(this.configPath, config);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async removeStaleConfigLock() {
|
|
80
|
+
let contents;
|
|
81
|
+
let metadata;
|
|
82
|
+
try {
|
|
83
|
+
[contents, metadata] = await Promise.all([
|
|
84
|
+
readFile(this.configLockPath, 'utf8'),
|
|
85
|
+
stat(this.configLockPath),
|
|
86
|
+
]);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error.code === 'ENOENT') return true;
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
const trimmed = contents.trim();
|
|
92
|
+
const pid = /^\d+$/.test(trimmed) ? Number(trimmed) : null;
|
|
93
|
+
const ownerIsDead = pid !== null && !processIsAlive(pid);
|
|
94
|
+
const lockExpired = Date.now() - metadata.mtimeMs >= CONFIG_LOCK_STALE_MS;
|
|
95
|
+
if (!ownerIsDead && !lockExpired) return false;
|
|
96
|
+
try {
|
|
97
|
+
if (await readFile(this.configLockPath, 'utf8') !== contents) return false;
|
|
98
|
+
await unlink(this.configLockPath);
|
|
99
|
+
return true;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error.code === 'ENOENT') return true;
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async withConfigLock(operation) {
|
|
107
|
+
await this.ensureDirectory();
|
|
108
|
+
const startedAt = Date.now();
|
|
109
|
+
let handle;
|
|
110
|
+
while (!handle) {
|
|
111
|
+
try {
|
|
112
|
+
handle = await open(this.configLockPath, 'wx', 0o600);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error.code !== 'EEXIST') throw error;
|
|
115
|
+
if (await this.removeStaleConfigLock()) continue;
|
|
116
|
+
if (Date.now() - startedAt >= CONFIG_LOCK_TIMEOUT_MS) throw new Error('Could not acquire source-sharing config lock.');
|
|
117
|
+
await delay(20);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
await handle.writeFile(`${process.pid}\n`);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
await handle.close();
|
|
124
|
+
await unlink(this.configLockPath).catch(() => {});
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
return await operation();
|
|
129
|
+
} finally {
|
|
130
|
+
await handle.close();
|
|
131
|
+
await unlink(this.configLockPath).catch((error) => { if (error.code !== 'ENOENT') throw error; });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async updateConfig(transform) {
|
|
136
|
+
return this.withConfigLock(async () => {
|
|
137
|
+
const current = await this.readConfig() ?? defaultConfig();
|
|
138
|
+
const next = transform(current);
|
|
139
|
+
await this.saveConfigUnlocked(next);
|
|
140
|
+
return next;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async config() {
|
|
145
|
+
return this.withConfigLock(async () => {
|
|
146
|
+
const existing = await this.readConfig();
|
|
147
|
+
if (existing) return existing;
|
|
148
|
+
const config = defaultConfig();
|
|
149
|
+
await this.saveConfigUnlocked(config);
|
|
150
|
+
return config;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async status() {
|
|
155
|
+
const config = await this.readConfig();
|
|
156
|
+
return { enabled: config?.enabled ?? true, disclosed: config?.disclosed ?? false, hasInstallationId: Boolean(config?.installationId), endpoint: this.endpoint, schemaVersion: 1 };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async configure(action) {
|
|
160
|
+
if (action === 'status') return this.status();
|
|
161
|
+
await this.updateConfig((config) => {
|
|
162
|
+
if (action === 'enable') config.enabled = true;
|
|
163
|
+
else if (action === 'disable') config.enabled = false;
|
|
164
|
+
else if (action === 'reset') Object.assign(config, { enabled: false, disclosed: true, installationId: null, token: null, tokenExpiresAt: null });
|
|
165
|
+
else throw new Error('Source sharing action must be status, enable, disable, or reset.');
|
|
166
|
+
config.disclosed = true;
|
|
167
|
+
return config;
|
|
168
|
+
});
|
|
169
|
+
return this.status();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async credentials(config) {
|
|
173
|
+
if (config.installationId && config.token && config.tokenExpiresAt && Date.parse(config.tokenExpiresAt) > this.now().getTime() + 60_000) return config;
|
|
174
|
+
let expectedState = config;
|
|
175
|
+
let body = config.installationId && config.token ? { installationId: config.installationId, token: config.token } : {};
|
|
176
|
+
let response = await this.fetch(`${this.endpoint}/v1/install`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(this.timeoutMs) });
|
|
177
|
+
if (response.status === 401 && body.installationId) {
|
|
178
|
+
config = await this.updateConfig((current) => {
|
|
179
|
+
if (current.installationId === body.installationId) Object.assign(current, { installationId: null, token: null, tokenExpiresAt: null });
|
|
180
|
+
return current;
|
|
181
|
+
});
|
|
182
|
+
expectedState = config;
|
|
183
|
+
body = {};
|
|
184
|
+
response = await this.fetch(`${this.endpoint}/v1/install`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(this.timeoutMs) });
|
|
185
|
+
}
|
|
186
|
+
if (!response.ok) throw new Error('community relay unavailable');
|
|
187
|
+
const identity = await response.json();
|
|
188
|
+
return this.updateConfig((current) => sameCredentialState(current, expectedState)
|
|
189
|
+
? { ...current, installationId: identity.installationId, token: identity.token, tokenExpiresAt: identity.expiresAt }
|
|
190
|
+
: current);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async preview(input) {
|
|
194
|
+
return normalizeCommunitySource(input);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async contribute(input) {
|
|
198
|
+
const source = normalizeCommunitySource(input);
|
|
199
|
+
try {
|
|
200
|
+
let config = await this.config();
|
|
201
|
+
if (!config.enabled) return { shared: false, reason: 'disabled' };
|
|
202
|
+
if (!config.disclosed) {
|
|
203
|
+
this.stderr(SOURCE_SHARING_NOTICE);
|
|
204
|
+
config = await this.updateConfig((current) => ({ ...current, disclosed: true }));
|
|
205
|
+
if (!config.enabled) return { shared: false, reason: 'disabled' };
|
|
206
|
+
}
|
|
207
|
+
config = await this.credentials(config);
|
|
208
|
+
let sent = await this.sendContribution(config, source);
|
|
209
|
+
if (sent.disabled) return { shared: false, reason: 'disabled' };
|
|
210
|
+
let response = sent.response;
|
|
211
|
+
if (response.status === 401) {
|
|
212
|
+
config = await this.updateConfig((current) => ({ ...current, tokenExpiresAt: null }));
|
|
213
|
+
config = await this.credentials(config);
|
|
214
|
+
sent = await this.sendContribution(config, source);
|
|
215
|
+
if (sent.disabled) return { shared: false, reason: 'disabled' };
|
|
216
|
+
response = sent.response;
|
|
217
|
+
}
|
|
218
|
+
if (!response.ok) {
|
|
219
|
+
return { shared: false, reason: 'unavailable' };
|
|
220
|
+
}
|
|
221
|
+
const result = await response.json();
|
|
222
|
+
const allowed = new Set(['accepted', 'sourceId', 'publicationStatus', 'uniqueContributors']);
|
|
223
|
+
if (!result || typeof result !== 'object' || Array.isArray(result) || Object.keys(result).some((key) => !allowed.has(key))) return { shared: false, reason: 'unavailable' };
|
|
224
|
+
if (result.accepted !== true || !/^community-[0-9a-f]{16}$/.test(result.sourceId)) return { shared: false, reason: 'unavailable' };
|
|
225
|
+
if (!['pending', 'published', 'rejected'].includes(result.publicationStatus)) return { shared: false, reason: 'unavailable' };
|
|
226
|
+
if (!Number.isSafeInteger(result.uniqueContributors) || result.uniqueContributors < 1 || result.uniqueContributors > 1_000_000_000) return { shared: false, reason: 'unavailable' };
|
|
227
|
+
return { shared: true, sourceId: result.sourceId, publicationStatus: result.publicationStatus, uniqueContributors: result.uniqueContributors };
|
|
228
|
+
} catch {
|
|
229
|
+
return { shared: false, reason: 'unavailable' };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async sendContribution(config, source) {
|
|
234
|
+
return this.withConfigLock(async () => {
|
|
235
|
+
const current = await this.readConfig() ?? config;
|
|
236
|
+
if (!current.enabled) return { disabled: true };
|
|
237
|
+
const envelope = createSourceContributionEnvelope({ installationId: current.installationId, token: current.token, source, skillVersion: SKILL_VERSION });
|
|
238
|
+
const response = await this.fetch(`${this.endpoint}/v1/sources`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(envelope), signal: AbortSignal.timeout(this.timeoutMs) });
|
|
239
|
+
return { disabled: false, response };
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async list() {
|
|
244
|
+
if (this.readUnavailable) return [];
|
|
245
|
+
try {
|
|
246
|
+
const response = await this.fetch(`${this.endpoint}/v1/sources`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(this.timeoutMs) });
|
|
247
|
+
if (!response.ok) return [];
|
|
248
|
+
return validateCommunitySourceList(await response.json());
|
|
249
|
+
} catch {
|
|
250
|
+
this.readUnavailable = true;
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
2
|
+
const SOURCE_ID = /^community-[0-9a-f]{16}$/;
|
|
3
|
+
export const SOURCE_KINDS = new Set(['direct-employer', 'professional-network', 'social-feed', 'startup-network', 'community-thread', 'job-board', 'curated-board', 'inbound', 'user-supplied']);
|
|
4
|
+
|
|
5
|
+
function record(value, label) {
|
|
6
|
+
if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error(`${label} must be an object.`);
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function boundedString(value, label, max) {
|
|
11
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max) throw new Error(`${label} must be a non-empty string no longer than ${max} characters.`);
|
|
12
|
+
return value.trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function containsIdentityLike(value) {
|
|
16
|
+
const normalized = value.normalize('NFKC');
|
|
17
|
+
return /[^\s/@]+@(?:[^\s./@]+\.)+[^\s./@]+/u.test(normalized)
|
|
18
|
+
|| /\+?\p{Nd}[\p{Nd}\s().-]{7,}/u.test(normalized);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function terms(value, label) {
|
|
22
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 12) throw new Error(`${label} must be a non-empty array with at most 12 values.`);
|
|
23
|
+
const normalized = value.map((item, index) => boundedString(item, `${label}[${index}]`, 40).toLowerCase());
|
|
24
|
+
if (normalized.some((item) => containsIdentityLike(item) || /https?:\/\//i.test(item))) throw new Error(`${label} must not contain identity-like content.`);
|
|
25
|
+
return [...new Set(normalized)].sort();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function looksPersonal(url) {
|
|
29
|
+
return (/(^|\.)linkedin\.com$/i.test(url.hostname) && /^\/in\//i.test(url.pathname))
|
|
30
|
+
|| (/(^|\.)github\.com$/i.test(url.hostname) && /^\/[^/]+\/?$/i.test(url.pathname))
|
|
31
|
+
|| (/(^|\.)x\.com$/i.test(url.hostname) && /^\/(?!home(?:\/|$)|jobs(?:\/|$)|search(?:\/|$)|i\/)[^/]+(?:\/|$)/i.test(url.pathname));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function looksIdentityPath(pathname) {
|
|
35
|
+
const segments = pathname.split('/').filter(Boolean).map((segment) => segment.toLowerCase());
|
|
36
|
+
const namespaces = new Set(['user', 'users', 'profile', 'profiles', 'member', 'members', 'author', 'authors', 'person', 'people']);
|
|
37
|
+
return segments.some((segment, index) => namespaces.has(segment) && index < segments.length - 1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function decodedPathname(pathname) {
|
|
41
|
+
let current = pathname;
|
|
42
|
+
for (let pass = 0; pass < 5; pass += 1) {
|
|
43
|
+
let decoded;
|
|
44
|
+
try { decoded = decodeURIComponent(current); } catch { throw new Error('community source.baseUrl path encoding is invalid.'); }
|
|
45
|
+
if (decoded === current) return decoded;
|
|
46
|
+
current = decoded;
|
|
47
|
+
}
|
|
48
|
+
throw new Error('community source.baseUrl path encoding is too deeply nested.');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function looksCredentialLikePath(pathname) {
|
|
52
|
+
return pathname.split('/').filter(Boolean).some((segment) => {
|
|
53
|
+
const normalized = segment.normalize('NFKC');
|
|
54
|
+
if (/^[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}$/.test(normalized)) return true;
|
|
55
|
+
if (/^(?:access[-_]?token|api[-_]?key|auth(?:orization)?|bearer|client[-_]?secret|password|secret|token)(?:[=:.]|\s+)(?:bearer\s+)?[A-Za-z0-9._~+/=-]{8,}$/i.test(normalized)) return true;
|
|
56
|
+
if (/^(?:cfat_|github_pat_|gh[pousr]_|[spr]k_(?:live|test)_|xox[baprs]-)[A-Za-z0-9_-]{8,}$/i.test(normalized)) return true;
|
|
57
|
+
const opaque = normalized.replace(/=+$/, '');
|
|
58
|
+
if (!/^[A-Za-z0-9_-]{20,}$/.test(opaque)) return false;
|
|
59
|
+
return (/[a-z]/.test(opaque) && /[A-Z]/.test(opaque)) || /\d/.test(opaque) || /[-_]/.test(opaque);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isPublicHostname(hostname) {
|
|
64
|
+
const value = hostname.toLowerCase();
|
|
65
|
+
if (value === 'localhost' || value.endsWith('.localhost') || value.endsWith('.local') || value.endsWith('.internal')) return false;
|
|
66
|
+
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(value) || value.startsWith('[')) return false;
|
|
67
|
+
return value.includes('.');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function hostnameMatches(hostname, suffix) {
|
|
71
|
+
return hostname === suffix || hostname.endsWith(`.${suffix}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isRepeatableCommunitySourceRoute(url) {
|
|
75
|
+
const hostname = url.hostname.toLowerCase();
|
|
76
|
+
const segments = url.pathname.split('/').filter(Boolean).map((segment) => segment.toLowerCase());
|
|
77
|
+
if (segments.length === 0) return true;
|
|
78
|
+
|
|
79
|
+
const path = `/${segments.join('/')}`;
|
|
80
|
+
const last = segments.at(-1) ?? '';
|
|
81
|
+
const uuid = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i;
|
|
82
|
+
if (uuid.test(path) || /^\d{4,}(?:[-_].*)?$/.test(last) || segments.some((segment) => /^(apply|application)$/.test(segment))) return false;
|
|
83
|
+
|
|
84
|
+
if (hostnameMatches(hostname, 'myworkdayjobs.com') && segments.some((segment, index) => segment === 'job' && index < segments.length - 1)) return false;
|
|
85
|
+
if (hostnameMatches(hostname, 'linkedin.com') && segments[0] === 'jobs' && segments[1] === 'view') return false;
|
|
86
|
+
if (hostnameMatches(hostname, 'greenhouse.io') && segments.some((segment, index) => segment === 'jobs' && index < segments.length - 1)) return false;
|
|
87
|
+
if (hostname === 'jobs.lever.co' && segments.length >= 2) return false;
|
|
88
|
+
if (hostname === 'jobs.ashbyhq.com' && segments.length >= 2) return false;
|
|
89
|
+
if (hostname === 'apply.workable.com' && segments.some((segment, index) => segment === 'j' && index < segments.length - 1)) return false;
|
|
90
|
+
if (hostname === 'jobs.smartrecruiters.com' && segments.length >= 2) return false;
|
|
91
|
+
if (segments.some((segment, index) => segment === 'job' && index < segments.length - 1)) return false;
|
|
92
|
+
|
|
93
|
+
if (hostnameMatches(hostname, 'myworkdayjobs.com')) return segments.includes('jobs') || ['external', 'internal', 'careers'].includes(last);
|
|
94
|
+
if (hostnameMatches(hostname, 'linkedin.com')) return segments[0] === 'jobs' && (segments.length === 1 || ['search', 'collections'].includes(segments[1]));
|
|
95
|
+
if (hostnameMatches(hostname, 'greenhouse.io')) return segments.length === 1 || last === 'jobs';
|
|
96
|
+
if (hostname === 'jobs.lever.co' || hostname === 'jobs.ashbyhq.com' || hostname === 'jobs.smartrecruiters.com') return segments.length === 1;
|
|
97
|
+
if (hostname === 'apply.workable.com') return segments.length === 1 || last === 'jobs';
|
|
98
|
+
|
|
99
|
+
if (/\.(?:rss|atom|xml|json)$/i.test(last)) return /(?:feed|jobs?|openings|careers)/i.test(path);
|
|
100
|
+
const collectionCues = new Set(['careers', 'openings', 'positions', 'vacancies', 'opportunities', 'jobs', 'job-search', 'job-listings', 'job-index', 'directory', 'feed', 'rss', 'atom', 'open-roles', 'available-jobs']);
|
|
101
|
+
const collectionQualifiers = new Set(['search', 'list', 'index', 'directory', 'feed', 'openings', 'engineering', 'product', 'design', 'sales', 'marketing', 'operations', 'finance', 'legal', 'people', 'remote']);
|
|
102
|
+
return segments.some((segment, index) => collectionCues.has(segment)
|
|
103
|
+
&& (index === segments.length - 1 || (index === segments.length - 2 && collectionQualifiers.has(segments[index + 1]))));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function normalizeCommunitySource(input) {
|
|
107
|
+
const value = record(input, 'community source');
|
|
108
|
+
const allowed = new Set(['name', 'baseUrl', 'kind', 'regions', 'roleFamilies', 'requiresSession']);
|
|
109
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown community source property: ${key}.`);
|
|
110
|
+
const name = boundedString(value.name, 'community source.name', 120);
|
|
111
|
+
if (containsIdentityLike(name) || /https?:\/\//i.test(name)) throw new Error('community source.name must not contain identity-like content.');
|
|
112
|
+
const url = new URL(boundedString(value.baseUrl, 'community source.baseUrl', 1000));
|
|
113
|
+
if (url.protocol !== 'https:' || url.username || url.password) throw new Error('community source.baseUrl must be a public HTTPS URL.');
|
|
114
|
+
url.hostname = url.hostname.replace(/\.+$/, '');
|
|
115
|
+
if (!isPublicHostname(url.hostname)) throw new Error('community source.baseUrl must use a public internet hostname.');
|
|
116
|
+
if (containsIdentityLike(url.hostname)) throw new Error('community source.baseUrl must not contain identity-like content.');
|
|
117
|
+
const decodedPath = decodedPathname(url.pathname).normalize('NFKC');
|
|
118
|
+
if (containsIdentityLike(decodedPath)) throw new Error('community source.baseUrl must not contain identity-like content.');
|
|
119
|
+
if (looksIdentityPath(decodedPath)) throw new Error('community source.baseUrl must not contain an identity-like path.');
|
|
120
|
+
url.pathname = decodedPath;
|
|
121
|
+
if (looksPersonal(url)) throw new Error('community source.baseUrl must not be a profile or personal URL.');
|
|
122
|
+
if (!isRepeatableCommunitySourceRoute(url)) throw new Error('community source.baseUrl must identify a repeatable discovery surface, not a one-off job.');
|
|
123
|
+
if (looksCredentialLikePath(decodedPath)) throw new Error('community source.baseUrl must not contain credential-like path segments.');
|
|
124
|
+
url.search = '';
|
|
125
|
+
url.hash = '';
|
|
126
|
+
const kind = boundedString(value.kind, 'community source.kind', 40).toLowerCase();
|
|
127
|
+
if (!SOURCE_KINDS.has(kind)) throw new Error('community source.kind is invalid.');
|
|
128
|
+
if (typeof value.requiresSession !== 'boolean') throw new Error('community source.requiresSession must be a Boolean.');
|
|
129
|
+
return {
|
|
130
|
+
name,
|
|
131
|
+
baseUrl: url.toString().replace(/\/+$/, ''),
|
|
132
|
+
kind,
|
|
133
|
+
regions: terms(value.regions, 'community source.regions'),
|
|
134
|
+
roleFamilies: terms(value.roleFamilies, 'community source.roleFamilies'),
|
|
135
|
+
requiresSession: value.requiresSession,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function createSourceContributionEnvelope({ installationId, token, source, skillVersion }) {
|
|
140
|
+
return validateSourceContributionEnvelope({ schemaVersion: 1, skillVersion, installationId, token, source });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function validateSourceContributionEnvelope(input) {
|
|
144
|
+
const value = record(input, 'source contribution');
|
|
145
|
+
const allowed = new Set(['schemaVersion', 'skillVersion', 'installationId', 'token', 'source']);
|
|
146
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown source contribution property: ${key}.`);
|
|
147
|
+
if (value.schemaVersion !== 1) throw new Error('Unsupported source contribution schema version.');
|
|
148
|
+
const installationId = boundedString(value.installationId, 'source contribution.installationId', 36);
|
|
149
|
+
if (!UUID.test(installationId)) throw new Error('source contribution.installationId is invalid.');
|
|
150
|
+
return {
|
|
151
|
+
schemaVersion: 1,
|
|
152
|
+
skillVersion: boundedString(value.skillVersion, 'source contribution.skillVersion', 40),
|
|
153
|
+
installationId,
|
|
154
|
+
token: boundedString(value.token, 'source contribution.token', 2048),
|
|
155
|
+
source: normalizeCommunitySource(value.source),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function communitySourceId(source) {
|
|
160
|
+
const normalized = normalizeCommunitySource(source);
|
|
161
|
+
const bytes = new TextEncoder().encode(normalized.baseUrl);
|
|
162
|
+
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
|
|
163
|
+
return `community-${[...digest].map((value) => value.toString(16).padStart(2, '0')).join('').slice(0, 16)}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function validateCommunitySourceList(input) {
|
|
167
|
+
const value = record(input, 'community source list');
|
|
168
|
+
for (const key of Object.keys(value)) if (!['version', 'sources'].includes(key)) throw new Error(`Unknown community source list property: ${key}.`);
|
|
169
|
+
if (value.version !== 1 || !Array.isArray(value.sources) || value.sources.length > 500) throw new Error('Invalid community source list.');
|
|
170
|
+
return value.sources.map((entry) => {
|
|
171
|
+
const source = record(entry, 'community source entry');
|
|
172
|
+
const allowed = new Set(['sourceId', 'name', 'baseUrl', 'kind', 'regions', 'roleFamilies', 'requiresSession', 'registryStatus', 'contributionCount']);
|
|
173
|
+
for (const key of Object.keys(source)) if (!allowed.has(key)) throw new Error(`Unknown community source entry property: ${key}.`);
|
|
174
|
+
if (!SOURCE_ID.test(source.sourceId)) throw new Error('community source entry.sourceId is invalid.');
|
|
175
|
+
if (source.registryStatus !== 'community-reviewed') throw new Error('community source entry.registryStatus is invalid.');
|
|
176
|
+
if (!Number.isSafeInteger(source.contributionCount) || source.contributionCount < 1 || source.contributionCount > 1_000_000_000) throw new Error('community source entry.contributionCount is invalid.');
|
|
177
|
+
const normalized = normalizeCommunitySource(Object.fromEntries(['name', 'baseUrl', 'kind', 'regions', 'roleFamilies', 'requiresSession'].map((key) => [key, source[key]])));
|
|
178
|
+
return { sourceId: source.sourceId, ...normalized, registryStatus: source.registryStatus, contributionCount: source.contributionCount };
|
|
179
|
+
});
|
|
180
|
+
}
|
|
@@ -4,6 +4,7 @@ import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import test from 'node:test';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
7
8
|
|
|
8
9
|
import { buildReview, commandCategory, durationBucket, migrateProfile, profileStatus, scoreJob, telemetryJobAssessed, validateLedgerEntry, validateProfile, validateSubmissionTelemetry } from '../scripts/job-application.mjs';
|
|
9
10
|
|
|
@@ -68,7 +69,7 @@ test('validates a candidate-defined target profile', () => {
|
|
|
68
69
|
test('returns the canonical resume path for direct browser uploads', async (t) => {
|
|
69
70
|
const directory = await mkdtemp(join(tmpdir(), 'public-job-agent-resume-path-'));
|
|
70
71
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
71
|
-
const script = new URL('../scripts/job-application.mjs', import.meta.url)
|
|
72
|
+
const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
|
|
72
73
|
const resume = join(directory, 'resume.pdf');
|
|
73
74
|
await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false }));
|
|
74
75
|
await writeFile(resume, '%PDF-1.7\ncanonical resume fixture');
|
|
@@ -238,7 +239,7 @@ test('requires review at each ten confirmed submissions', () => {
|
|
|
238
239
|
test('deduplicates ledger entries by normalized URL', async (t) => {
|
|
239
240
|
const directory = await mkdtemp(join(tmpdir(), 'public-job-agent-'));
|
|
240
241
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
241
|
-
const script = new URL('../scripts/job-application.mjs', import.meta.url)
|
|
242
|
+
const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
|
|
242
243
|
const entry = {
|
|
243
244
|
id: 'example-role-1', company: 'Example', role: 'Senior Product Engineer', url: 'https://jobs.example.com/123?utm_source=x', source: 'company', score: 80, status: 'submitted', submittedAt: '2026-01-15T10:00:00Z', approval: 'STANDING AUTHORIZATION', answers: {},
|
|
244
245
|
telemetry: { durationBucket: '5-15m', fieldsFilled: 14, shortAnswerCount: 2, resumeUploaded: true },
|
|
@@ -253,13 +254,13 @@ test('deduplicates ledger entries by normalized URL', async (t) => {
|
|
|
253
254
|
}));
|
|
254
255
|
assert.equal(relabelled.duplicate, true);
|
|
255
256
|
assert.equal((await readFile(join(directory, 'applications.ndjson'), 'utf8')).includes('telemetry'), false);
|
|
256
|
-
assert.equal((await stat(join(directory, 'applications.ndjson'))).mode & 0o777, 0o600);
|
|
257
|
+
if (process.platform !== 'win32') assert.equal((await stat(join(directory, 'applications.ndjson'))).mode & 0o777, 0o600);
|
|
257
258
|
});
|
|
258
259
|
|
|
259
260
|
test('warns on same-company role matches and serializes concurrent duplicate submissions', async (t) => {
|
|
260
261
|
const directory = await mkdtemp(join(tmpdir(), 'public-job-agent-dedup-'));
|
|
261
262
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
262
|
-
const script = new URL('../scripts/job-application.mjs', import.meta.url)
|
|
263
|
+
const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
|
|
263
264
|
await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false }));
|
|
264
265
|
const env = { ...process.env, JOB_APPLICATION_AGENT_STATE_DIR: directory };
|
|
265
266
|
const base = {
|
|
@@ -283,7 +284,7 @@ test('warns on same-company role matches and serializes concurrent duplicate sub
|
|
|
283
284
|
test('records structured outcomes idempotently without duplicate rows', async (t) => {
|
|
284
285
|
const directory = await mkdtemp(join(tmpdir(), 'public-job-agent-outcomes-'));
|
|
285
286
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
286
|
-
const script = new URL('../scripts/job-application.mjs', import.meta.url)
|
|
287
|
+
const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
|
|
287
288
|
await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false }));
|
|
288
289
|
await writeFile(join(directory, 'applications.ndjson'), `${JSON.stringify({
|
|
289
290
|
id: 'example-role-1', company: 'Example', role: 'Senior Product Engineer', url: 'https://jobs.example.com/123', source: 'company', score: 88,
|
|
@@ -306,7 +307,7 @@ test('records structured outcomes idempotently without duplicate rows', async (t
|
|
|
306
307
|
test('records bounded interview quality and failure-point enrichment idempotently', async (t) => {
|
|
307
308
|
const directory = await mkdtemp(join(tmpdir(), 'public-job-agent-interview-quality-'));
|
|
308
309
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
309
|
-
const script = new URL('../scripts/job-application.mjs', import.meta.url)
|
|
310
|
+
const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
|
|
310
311
|
await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false }));
|
|
311
312
|
await writeFile(join(directory, 'applications.ndjson'), `${JSON.stringify({
|
|
312
313
|
id: 'example-role-1', company: 'Example', role: 'Staff Product Engineer', url: 'https://jobs.example.com/123', source: 'company', score: 91,
|
|
@@ -391,7 +392,7 @@ test('preserves interview quality after a later final outcome', () => {
|
|
|
391
392
|
test('acknowledges a generated review only through the explicit CLI command', async (t) => {
|
|
392
393
|
const directory = await mkdtemp(join(tmpdir(), 'public-job-agent-review-'));
|
|
393
394
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
394
|
-
const script = new URL('../scripts/job-application.mjs', import.meta.url)
|
|
395
|
+
const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
|
|
395
396
|
await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false }));
|
|
396
397
|
const entries = Array.from({ length: 10 }, (_, index) => ({
|
|
397
398
|
id: `role-${index}`, company: `Company ${index}`, role: 'Senior Engineer', url: `https://jobs.example.com/${index}`,
|
|
@@ -414,6 +415,7 @@ test('maps commands and durations to bounded telemetry categories', () => {
|
|
|
414
415
|
assert.equal(commandCategory(['ledger', 'add', '--stdin']), 'apply');
|
|
415
416
|
assert.equal(commandCategory(['ledger', 'outcome', '--stdin']), 'outcome');
|
|
416
417
|
assert.equal(commandCategory(['score', '--stdin']), 'assess');
|
|
418
|
+
assert.equal(commandCategory(['sources', 'list']), 'search');
|
|
417
419
|
assert.equal(durationBucket(700), 'under-1s');
|
|
418
420
|
assert.equal(durationBucket(70_000), '1-2m');
|
|
419
421
|
assert.equal(durationBucket(2_000_000), '15m-plus');
|
|
@@ -436,11 +438,11 @@ test('builds a structured assessment event without description or candidate prof
|
|
|
436
438
|
test('telemetry CLI controls are private and reset removes anonymous credentials', async (t) => {
|
|
437
439
|
const directory = await mkdtemp(join(tmpdir(), 'public-job-agent-telemetry-'));
|
|
438
440
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
439
|
-
const script = new URL('../scripts/job-application.mjs', import.meta.url)
|
|
441
|
+
const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
|
|
440
442
|
const env = { ...process.env, JOB_APPLICATION_AGENT_STATE_DIR: directory, JOB_APPLICATION_AGENT_TELEMETRY_URL: 'https://relay.invalid' };
|
|
441
443
|
const disabled = JSON.parse(execFileSync(process.execPath, [script, 'telemetry', 'disable'], { env, encoding: 'utf8' }));
|
|
442
444
|
assert.equal(disabled.enabled, false);
|
|
443
445
|
const reset = JSON.parse(execFileSync(process.execPath, [script, 'telemetry', 'reset'], { env, encoding: 'utf8' }));
|
|
444
446
|
assert.equal(reset.hasInstallationId, false);
|
|
445
|
-
assert.equal((await stat(join(directory, 'telemetry.json'))).mode & 0o777, 0o600);
|
|
447
|
+
if (process.platform !== 'win32') assert.equal((await stat(join(directory, 'telemetry.json'))).mode & 0o777, 0o600);
|
|
446
448
|
});
|
|
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|
|
3
3
|
|
|
4
4
|
import { prepareTelemetryInput } from '../scripts/telemetry-client.mjs';
|
|
5
5
|
import { validateEvent } from '../scripts/telemetry-schema.mjs';
|
|
6
|
+
import { normalizeCommunitySource } from '../scripts/source-community-schema.mjs';
|
|
6
7
|
|
|
7
8
|
const forbiddenProperties = [
|
|
8
9
|
'name', 'email', 'phone', 'exactAddress', 'linkedin', 'github', 'portfolio', 'candidateLocation',
|
|
@@ -40,3 +41,18 @@ test('privacy audit strips the full query and fragment before job URL hashing',
|
|
|
40
41
|
assert.equal(serialized.includes('secret'), false);
|
|
41
42
|
assert.equal(serialized.includes('jobs.example.com/role/123'), false);
|
|
42
43
|
});
|
|
44
|
+
|
|
45
|
+
test('privacy audit strips identity-bearing source parameters and rejects personal source metadata', () => {
|
|
46
|
+
const source = normalizeCommunitySource({
|
|
47
|
+
name: 'Example Engineering Board',
|
|
48
|
+
baseUrl: 'https://jobs.example.org/openings/engineering?email=candidate@example.com&token=secret#private',
|
|
49
|
+
kind: 'job-board',
|
|
50
|
+
regions: ['global'],
|
|
51
|
+
roleFamilies: ['engineering'],
|
|
52
|
+
requiresSession: false,
|
|
53
|
+
});
|
|
54
|
+
assert.equal(source.baseUrl, 'https://jobs.example.org/openings/engineering');
|
|
55
|
+
assert.equal(JSON.stringify(source).includes('candidate@example.com'), false);
|
|
56
|
+
assert.throws(() => normalizeCommunitySource({ ...source, name: 'candidate@example.com' }), /identity/i);
|
|
57
|
+
assert.throws(() => normalizeCommunitySource({ ...source, baseUrl: 'https://linkedin.com/in/candidate' }), /profile or personal/i);
|
|
58
|
+
});
|