job-application-agent 3.2.1 → 3.4.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.
@@ -1,13 +1,14 @@
1
1
  import { chmod, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
 
4
- import { createTelemetryEnvelope, jobIdentity, validateEvent } from './telemetry-schema.mjs';
4
+ import { createTelemetryEnvelope, jobIdentity, validateEvent, validateTelemetryIdentity } from './telemetry-schema.mjs';
5
5
  import { SKILL_VERSION } from './version.mjs';
6
6
 
7
7
  export { SKILL_VERSION };
8
8
 
9
9
  export const DEFAULT_TELEMETRY_ENDPOINT = process.env.JOB_APPLICATION_AGENT_TELEMETRY_URL ?? 'https://job-application-agent-telemetry.varora1406.workers.dev';
10
- export const TELEMETRY_NOTICE = 'Anonymous usage analytics are enabled by default. They include structured job and workflow metrics, but never your identity, resume, prompts, form answers, browser data, or candidate profile. Run `telemetry disable` to opt out or `telemetry preview` to inspect an event.\n';
10
+ export const TELEMETRY_NOTICE = 'Usage analytics are enabled by default. They include structured job and workflow metrics. Name and email sharing has a separate disclosure and opt-out. Resume content, other profile fields, prompts, form answers, browser data, and raw errors are never sent. Run `telemetry disable` to stop all analytics or `telemetry preview` to inspect an event.\n';
11
+ export const IDENTITY_NOTICE = 'Name and email sharing is enabled by default. Starting with the next command, JobAgent shares the name and email explicitly saved in your candidate profile with the maintainer through private PostHog usage analytics for support and product improvement. Run `telemetry identity disable` to keep future analytics anonymous, or `telemetry disable` to stop all analytics. Opting out rotates the analytics ID; previously collected data is retained under the analytics retention policy.\n';
11
12
 
12
13
  const CONFIG_VERSION = 1;
13
14
  const CONFIG_FILE = 'telemetry.json';
@@ -38,13 +39,14 @@ async function writePrivate(file, value) {
38
39
  }
39
40
 
40
41
  export class TelemetryClient {
41
- constructor({ stateDir, endpoint = DEFAULT_TELEMETRY_ENDPOINT, fetch: fetchFn = globalThis.fetch, stderr = (value) => process.stderr.write(value), now = () => new Date(), timeoutMs = Number(process.env.JOB_APPLICATION_AGENT_TELEMETRY_TIMEOUT_MS ?? 3000) }) {
42
+ constructor({ stateDir, endpoint = DEFAULT_TELEMETRY_ENDPOINT, fetch: fetchFn = globalThis.fetch, stderr = (value) => process.stderr.write(value), now = () => new Date(), timeoutMs = Number(process.env.JOB_APPLICATION_AGENT_TELEMETRY_TIMEOUT_MS ?? 3000), readIdentity = () => undefined }) {
42
43
  this.stateDir = stateDir;
43
44
  this.endpoint = endpoint.replace(/\/$/, '');
44
45
  this.fetch = fetchFn;
45
46
  this.stderr = stderr;
46
47
  this.now = now;
47
48
  this.timeoutMs = timeoutMs;
49
+ this.readIdentity = readIdentity;
48
50
  }
49
51
 
50
52
  get configPath() { return join(this.stateDir, CONFIG_FILE); }
@@ -57,7 +59,7 @@ export class TelemetryClient {
57
59
  async readConfig() {
58
60
  try {
59
61
  const value = JSON.parse(await readFile(this.configPath, 'utf8'));
60
- return { version: CONFIG_VERSION, enabled: value.enabled !== false, disclosed: value.disclosed === true, graceConsumed: value.graceConsumed === true, installationEventPending: value.installationEventPending === true, installationId: value.installationId ?? null, token: value.token ?? null, tokenExpiresAt: value.tokenExpiresAt ?? null };
62
+ return { version: CONFIG_VERSION, enabled: value.enabled !== false, disclosed: value.disclosed === true, graceConsumed: value.graceConsumed === true, installationEventPending: value.installationEventPending === true, installationId: value.installationId ?? null, token: value.token ?? null, tokenExpiresAt: value.tokenExpiresAt ?? null, identityEnabled: value.identityEnabled !== false, identityDisclosed: value.identityDisclosed === true };
61
63
  } catch (error) {
62
64
  if (error.code === 'ENOENT') return null;
63
65
  return { version: CONFIG_VERSION, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false, installationId: null, token: null, tokenExpiresAt: null };
@@ -101,7 +103,13 @@ export class TelemetryClient {
101
103
  config.graceConsumed = true;
102
104
  await this.saveConfig(config);
103
105
  }
104
- return { command, enabled: config.enabled, allowSend: config.enabled && allowSend, installationEventPending: config.installationEventPending === true };
106
+ const allowIdentity = config.identityEnabled !== false && config.identityDisclosed === true;
107
+ if (config.enabled && config.identityEnabled !== false && !config.identityDisclosed) {
108
+ this.stderr(IDENTITY_NOTICE);
109
+ config.identityDisclosed = true;
110
+ await this.saveConfig(config);
111
+ }
112
+ return { command, enabled: config.enabled, allowSend: config.enabled && allowSend, allowIdentity, installationEventPending: config.installationEventPending === true };
105
113
  }
106
114
 
107
115
  async credentials(config) {
@@ -128,13 +136,17 @@ export class TelemetryClient {
128
136
  if (session.unavailable) return { sent: false, reason: 'unavailable' };
129
137
  let config = await this.readConfig();
130
138
  if (!config?.enabled) return { sent: false, reason: 'disabled' };
139
+ let identity;
140
+ if (session.allowIdentity === true && config.identityEnabled !== false && config.identityDisclosed === true) {
141
+ try { identity = validateTelemetryIdentity(await this.readIdentity()); } catch { /* Missing or invalid identity never blocks anonymous analytics. */ }
142
+ }
131
143
  config = await this.credentials(config);
132
- const payload = createTelemetryEnvelope({ installationId: config.installationId, token: config.token, event, skillVersion: SKILL_VERSION });
144
+ const payload = createTelemetryEnvelope({ installationId: config.installationId, token: config.token, event, skillVersion: SKILL_VERSION, identity });
133
145
  let response = await this.fetch(`${this.endpoint}/v1/events`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), signal: AbortSignal.timeout(this.timeoutMs) });
134
146
  if (response.status === 401) {
135
147
  config.tokenExpiresAt = null;
136
148
  config = await this.credentials(config);
137
- response = await this.fetch(`${this.endpoint}/v1/events`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(createTelemetryEnvelope({ installationId: config.installationId, token: config.token, event, skillVersion: SKILL_VERSION })), signal: AbortSignal.timeout(this.timeoutMs) });
149
+ response = await this.fetch(`${this.endpoint}/v1/events`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(createTelemetryEnvelope({ installationId: config.installationId, token: config.token, event, skillVersion: SKILL_VERSION, identity })), signal: AbortSignal.timeout(this.timeoutMs) });
138
150
  }
139
151
  if (!response.ok) {
140
152
  session.unavailable = true;
@@ -154,7 +166,22 @@ export class TelemetryClient {
154
166
 
155
167
  async status() {
156
168
  const config = await this.readConfig();
157
- return { enabled: config?.enabled ?? true, disclosed: config?.disclosed ?? false, hasInstallationId: Boolean(config?.installationId), endpoint: this.endpoint, schemaVersion: 1 };
169
+ return { enabled: config?.enabled ?? true, disclosed: config?.disclosed ?? false, hasInstallationId: Boolean(config?.installationId), installationId: config?.installationId ?? null, identityEnabled: config?.identityEnabled ?? true, identityDisclosed: config?.identityDisclosed ?? false, endpoint: this.endpoint, schemaVersion: 1 };
170
+ }
171
+
172
+ async configureIdentity(action) {
173
+ if (action === 'status') return this.status();
174
+ if (!['enable', 'disable'].includes(action)) throw new Error('Identity action must be status, enable, or disable.');
175
+ const current = await this.readConfig() ?? { version: CONFIG_VERSION, enabled: true, disclosed: false, graceConsumed: true, installationEventPending: true };
176
+ const enabled = action === 'enable';
177
+ // Rotate at both boundaries to avoid identifying an earlier anonymous period.
178
+ if (enabled !== (current.identityEnabled !== false)) {
179
+ Object.assign(current, { installationId: null, token: null, tokenExpiresAt: null });
180
+ }
181
+ if (enabled && current.identityEnabled === false) current.identityDisclosed = false;
182
+ current.identityEnabled = enabled;
183
+ await this.saveConfig(current);
184
+ return this.status();
158
185
  }
159
186
 
160
187
  async configure(action) {
@@ -31,6 +31,9 @@ const FAILURE_POINTS = values('role-scope', 'company-problem', 'constraints', 'i
31
31
  const ERROR_CODES = values('invalid_input', 'network_failure', 'relay_unavailable', 'authentication_required', 'site_changed', 'upload_failed', 'submission_unconfirmed', 'rate_limited', 'internal_error');
32
32
  const MATCH_TAGS = values('role_family', 'seniority', 'skills', 'industry', 'location', 'remote', 'salary', 'ai', 'product', 'leadership', 'authorization');
33
33
  const GAP_TAGS = values('role_family', 'seniority', 'skills', 'industry', 'location', 'salary_unknown', 'salary_below', 'authorization_unclear', 'sponsorship', 'experience', 'domain', 'other');
34
+ const DISCOVERY_SOURCE_KEYS = values('direct-company-careers', 'linkedin-jobs-feed', 'x-hiring-feed', 'yc-work-at-a-startup', 'yc-company-directory', 'hacker-news-who-is-hiring', 'we-work-remotely', 'a16z-build-jobs', 'engg-space', 'js-guru-jobs', 'linux-careers', 'indeed', 'recruiter-inbound', 'user-supplied-leads', 'community');
35
+ const DISCOVERY_BLOCKERS = values('login', 'mfa', 'captcha', 'site-error', 'access-unavailable');
36
+ const CONCENTRATION_REASONS = values('stronger-fit', 'alternatives-exhausted', 'access-blocked', 'candidate-directed');
34
37
 
35
38
  const text = (max, identitySafe = false) => ({ kind: 'text', max, identitySafe });
36
39
  const integer = (min, max) => ({ kind: 'integer', min, max });
@@ -43,6 +46,7 @@ const JOB = {
43
46
  };
44
47
 
45
48
  export const EVENT_SCHEMAS = {
49
+ source_checked: { required: { sourceId: enumValue(DISCOVERY_SOURCE_KEYS), status: enumValue(values('searched', 'blocked')), reviewedCount: integer(0, 10000), qualifiedCount: integer(0, 10000) }, optional: { blocker: enumValue(DISCOVERY_BLOCKERS) } },
46
50
  installation_started: { required: { osFamily: enumValue(values('macos', 'linux', 'windows', 'other')), nodeMajor: integer(20, 99), submissionMode: enumValue(SUBMISSION_MODES) } },
47
51
  command_completed: { required: { command: enumValue(COMMANDS), result: enumValue(RESULTS), durationBucket: enumValue(DURATIONS) } },
48
52
  job_discovered: { required: { ...JOB, source: enumValue(SOURCES), jobCountry: text(80, true), workMode: enumValue(WORK_MODES), seniority: enumValue(SENIORITIES), employmentType: enumValue(EMPLOYMENT), roleFamily: enumValue(ROLE_FAMILIES) }, optional: { salaryCurrency: { kind: 'currency' }, salaryMin: integer(0, 10000000), salaryMax: integer(0, 10000000) } },
@@ -52,7 +56,7 @@ export const EVENT_SCHEMAS = {
52
56
  application_paused: { required: { jobHash: { kind: 'hash' }, ats: enumValue(ATS), stage: enumValue(STAGES), reason: enumValue(PAUSE_REASONS) } },
53
57
  application_skipped: { required: { jobHash: { kind: 'hash' }, reason: enumValue(SKIP_REASONS), fitScore: integer(0, 100), eligibility: enumValue(ELIGIBILITY) } },
54
58
  application_submitted: { required: { ...JOB, durationBucket: enumValue(DURATIONS), fieldsFilled: integer(0, 500), shortAnswerCount: integer(0, 100), resumeUploaded: boolean, approvalMode: enumValue(APPROVAL_MODES) } },
55
- round_completed: { required: { requestedCount: integer(1, 1000), submittedCount: integer(0, 1000), assessedCount: integer(0, 10000), skippedCount: integer(0, 10000), pausedCount: integer(0, 10000), errorCount: integer(0, 10000), durationBucket: enumValue(DURATIONS) } },
59
+ round_completed: { required: { requestedCount: integer(1, 1000), submittedCount: integer(0, 1000), assessedCount: integer(0, 10000), skippedCount: integer(0, 10000), pausedCount: integer(0, 10000), errorCount: integer(0, 10000), durationBucket: enumValue(DURATIONS) }, optional: { attemptedSourceCount: integer(0, 10000), searchedSourceCount: integer(0, 10000), blockedSourceCount: integer(0, 10000), maxSourceSharePercent: integer(0, 100), concentrationReason: enumValue(CONCENTRATION_REASONS) } },
56
60
  outcome_recorded: {
57
61
  required: { ...JOB, outcome: enumValue(OUTCOMES), daysSinceSubmission: integer(0, 3650) },
58
62
  optional: { interviewQuality: enumValue(INTERVIEW_QUALITIES), failurePoint: enumValue(FAILURE_POINTS) },
@@ -118,6 +122,11 @@ export function validateEvent(input) {
118
122
  }
119
123
  for (const [name, rule] of Object.entries(schema.optional ?? {})) if (name in input.properties) properties[name] = validateProperty(name, input.properties[name], rule);
120
124
  if (input.event === 'outcome_recorded' && properties.failurePoint && !properties.interviewQuality) throw new Error('failurePoint requires interviewQuality.');
125
+ if (input.event === 'source_checked') {
126
+ if (properties.qualifiedCount > properties.reviewedCount) throw new Error('qualifiedCount cannot exceed reviewedCount.');
127
+ if (properties.status === 'blocked' && (!properties.blocker || properties.reviewedCount !== 0 || properties.qualifiedCount !== 0)) throw new Error('Blocked sources require a blocker and zero counts.');
128
+ if (properties.status === 'searched' && properties.blocker) throw new Error('Searched sources cannot have a blocker.');
129
+ }
121
130
  const result = { event: input.event, properties };
122
131
  if (new TextEncoder().encode(JSON.stringify(result)).length > TELEMETRY_MAX_BYTES) throw new Error('Telemetry event exceeds 4 KB.');
123
132
  return result;
@@ -142,19 +151,37 @@ export async function jobIdentity(value) {
142
151
  return { jobHash: [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''), domain: new URL(canonical).hostname };
143
152
  }
144
153
 
145
- export function createTelemetryEnvelope({ installationId, token, event, properties, skillVersion }) {
154
+ // Identity is a separate allowlisted envelope field, never arbitrary event input.
155
+ export function validateTelemetryIdentity(input) {
156
+ if (!input || Array.isArray(input) || typeof input !== 'object') throw new Error('Telemetry identity must be an object.');
157
+ for (const key of Object.keys(input)) if (!['name', 'email'].includes(key)) throw new Error('Unknown telemetry identity property.');
158
+ const identity = {};
159
+ for (const [key, max] of [['name', 160], ['email', 254]]) {
160
+ if (!(key in input)) continue;
161
+ const value = input[key];
162
+ if (typeof value !== 'string' || !value.trim() || value.length > max || /[\x00-\x1f\x7f]/.test(value)) throw new Error('Invalid telemetry identity field.');
163
+ const normalized = value.trim();
164
+ if (key === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) throw new Error('Invalid telemetry identity email.');
165
+ identity[key] = normalized;
166
+ }
167
+ if (!Object.keys(identity).length) throw new Error('Telemetry identity must include name or email.');
168
+ return identity;
169
+ }
170
+
171
+ export function createTelemetryEnvelope({ installationId, token, event, properties, skillVersion, identity }) {
146
172
  if (!UUID.test(installationId)) throw new Error('installationId must be an anonymous UUID.');
147
173
  if (typeof token !== 'string' || token.length < 8 || token.length > 2048) throw new Error('token is invalid.');
148
174
  if (typeof skillVersion !== 'string' || !VERSION.test(skillVersion)) throw new Error('skillVersion is invalid.');
149
175
  const safe = validateEvent(typeof event === 'string' ? { event, properties } : event);
150
176
  const envelope = { schemaVersion: TELEMETRY_SCHEMA_VERSION, skillVersion, installationId, token, ...safe };
177
+ if (identity !== undefined) envelope.identity = validateTelemetryIdentity(identity);
151
178
  if (new TextEncoder().encode(JSON.stringify(envelope)).length > TELEMETRY_MAX_BYTES) throw new Error('Telemetry payload exceeds 4 KB.');
152
179
  return envelope;
153
180
  }
154
181
 
155
182
  export function validateTelemetryEnvelope(input) {
156
183
  if (!input || Array.isArray(input) || typeof input !== 'object') throw new Error('Telemetry payload must be an object.');
157
- const allowed = new Set(['schemaVersion', 'skillVersion', 'installationId', 'token', 'event', 'properties']);
184
+ const allowed = new Set(['schemaVersion', 'skillVersion', 'installationId', 'token', 'event', 'properties', 'identity']);
158
185
  for (const key of Object.keys(input)) if (!allowed.has(key)) throw new Error(`Unknown telemetry envelope property: ${key}.`);
159
186
  if (input.schemaVersion !== TELEMETRY_SCHEMA_VERSION) throw new Error('Unsupported telemetry schema version.');
160
187
  return createTelemetryEnvelope(input);
@@ -1 +1 @@
1
- export const SKILL_VERSION = '3.2.1';
1
+ export const SKILL_VERSION = '3.4.0';
@@ -5,8 +5,9 @@ import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import test from 'node:test';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { createServer } from 'node:http';
8
9
 
9
- import { buildReview, commandCategory, durationBucket, migrateProfile, profileStatus, scoreJob, telemetryJobAssessed, validateLedgerEntry, validateProfile, validateSubmissionTelemetry } from '../scripts/job-application.mjs';
10
+ import { buildReview, commandCategory, durationBucket, migrateProfile, profileStatus, scoreJob, telemetryErrorCode, telemetryJobAssessed, validateLedgerEntry, validateProfile, validateSubmissionTelemetry } from '../scripts/job-application.mjs';
10
11
 
11
12
  const target = {
12
13
  name: 'Test Candidate',
@@ -56,9 +57,9 @@ function isolatedCliEnv(directory) {
56
57
  };
57
58
  }
58
59
 
59
- function runCli(script, args, input, env) {
60
+ function runCli(script, args, input, env, runtimeArgs = []) {
60
61
  return new Promise((resolve) => {
61
- const child = spawn(process.execPath, [script, ...args], { env, stdio: ['pipe', 'pipe', 'pipe'] });
62
+ const child = spawn(process.execPath, [...runtimeArgs, script, ...args], { env, stdio: ['pipe', 'pipe', 'pipe'] });
62
63
  let stdout = '';
63
64
  let stderr = '';
64
65
  child.stdout.on('data', (chunk) => { stdout += chunk; });
@@ -88,6 +89,43 @@ test('returns the canonical resume path for direct browser uploads', async (t) =
88
89
  assert.deepEqual(result, { path: resume });
89
90
  });
90
91
 
92
+ test('preserves the Linux secret-tool install error for profile-dependent commands', async (t) => {
93
+ const directory = await mkdtemp(join(tmpdir(), 'job-agent-linux-profile-error-'));
94
+ t.after(() => rm(directory, { recursive: true, force: true }));
95
+ const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
96
+ await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false }));
97
+ const platformOverride = `data:text/javascript,${encodeURIComponent("Object.defineProperty(process, 'platform', { value: 'linux' });")}`;
98
+ const env = { ...isolatedCliEnv(directory), PATH: '' };
99
+
100
+ const result = await runCli(script, ['profile', 'field', 'name'], undefined, env, ['--import', platformOverride]);
101
+
102
+ assert.equal(result.code, 1);
103
+ assert.match(result.stderr, /secret-tool is not installed/);
104
+ assert.doesNotMatch(result.stderr, /profile needs migration/i);
105
+ });
106
+
107
+ test('preserves an unavailable Linux Secret Service error for profile-dependent commands', {
108
+ skip: process.platform === 'win32',
109
+ }, async (t) => {
110
+ const directory = await mkdtemp(join(tmpdir(), 'job-agent-linux-service-error-'));
111
+ const toolDirectory = await mkdtemp(join(tmpdir(), 'job-agent-secret-tool-'));
112
+ t.after(() => rm(directory, { recursive: true, force: true }));
113
+ t.after(() => rm(toolDirectory, { recursive: true, force: true }));
114
+ const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
115
+ const toolPath = join(toolDirectory, 'secret-tool');
116
+ const toolSource = '#!/bin/sh\nprintf "%s\\n" "secret-tool: Secret Service is unavailable" >&2\nexit 1\n';
117
+ await writeFile(toolPath, toolSource, { mode: 0o755 });
118
+ await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false }));
119
+ const platformOverride = `data:text/javascript,${encodeURIComponent("Object.defineProperty(process, 'platform', { value: 'linux' });")}`;
120
+ const env = { ...isolatedCliEnv(directory), PATH: toolDirectory };
121
+
122
+ const result = await runCli(script, ['profile', 'field', 'name'], undefined, env, ['--import', platformOverride]);
123
+
124
+ assert.equal(result.code, 1);
125
+ assert.match(result.stderr, /Secret Service could not read the profile/);
126
+ assert.doesNotMatch(result.stderr, /profile needs migration/i);
127
+ });
128
+
91
129
  test('migrates a legacy profile without discarding identity or salary preference', () => {
92
130
  const legacy = {
93
131
  name: 'Test Candidate', email: 'candidate@example.com', phone: '+1 555 0100',
@@ -428,6 +466,8 @@ test('maps commands and durations to bounded telemetry categories', () => {
428
466
  assert.equal(durationBucket(700), 'under-1s');
429
467
  assert.equal(durationBucket(70_000), '1-2m');
430
468
  assert.equal(durationBucket(2_000_000), '15m-plus');
469
+ assert.equal(telemetryErrorCode(new Error('Secret Service could not read the profile.')), 'authentication_required');
470
+ assert.equal(telemetryErrorCode(new Error('secret-tool is not installed.')), 'authentication_required');
431
471
  });
432
472
 
433
473
  test('builds a structured assessment event without description or candidate profile data', async () => {
@@ -453,5 +493,81 @@ test('telemetry CLI controls are private and reset removes anonymous credentials
453
493
  assert.equal(disabled.enabled, false);
454
494
  const reset = JSON.parse(execFileSync(process.execPath, [script, 'telemetry', 'reset'], { env, encoding: 'utf8' }));
455
495
  assert.equal(reset.hasInstallationId, false);
496
+ const identityDisabled = JSON.parse(execFileSync(process.execPath, [script, 'telemetry', 'identity', 'disable'], { env, encoding: 'utf8' }));
497
+ assert.equal(identityDisabled.identityEnabled, false);
498
+ assert.equal(identityDisabled.enabled, false);
499
+ const identityEnabled = JSON.parse(execFileSync(process.execPath, [script, 'telemetry', 'identity', 'enable'], { env, encoding: 'utf8' }));
500
+ assert.equal(identityEnabled.identityEnabled, true);
501
+ assert.equal(identityEnabled.enabled, false);
502
+ assert.equal(identityEnabled.identityDisclosed, false);
456
503
  if (process.platform !== 'win32') assert.equal((await stat(join(directory, 'telemetry.json'))).mode & 0o777, 0o600);
457
504
  });
505
+
506
+ test('CLI emits bounded source coverage without private evidence or attribution', async (t) => {
507
+ const directory = await mkdtemp(join(tmpdir(), 'job-agent-coverage-cli-'));
508
+ t.after(() => rm(directory, { recursive: true, force: true }));
509
+ const captured = [];
510
+ const server = createServer(async (request, response) => {
511
+ let raw = '';
512
+ for await (const chunk of request) raw += chunk;
513
+ captured.push(JSON.parse(raw));
514
+ response.setHeader('content-type', 'application/json');
515
+ response.end(JSON.stringify({ accepted: true }));
516
+ });
517
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
518
+ t.after(() => new Promise((resolve) => server.close(resolve)));
519
+ await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: true, disclosed: true, graceConsumed: true, installationEventPending: false, identityEnabled: false, installationId: crypto.randomUUID(), token: 'synthetic-token', tokenExpiresAt: '2099-01-01T00:00:00Z' }));
520
+ const env = { ...isolatedCliEnv(directory), JOB_APPLICATION_AGENT_TELEMETRY_URL: `http://127.0.0.1:${server.address().port}` };
521
+ const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
522
+ const started = await runCli(script, ['round', 'start', '--stdin'], { requestedCount: 1 }, env);
523
+ assert.equal(started.code, 0, started.stderr);
524
+ const roundId = JSON.parse(started.stdout).roundId;
525
+ const sourceId = 'community-0123456789abcdef';
526
+ const report = await runCli(script, ['round', 'source', '--stdin'], { roundId, sourceId, status: 'searched', reviewedCount: 8, qualifiedCount: 2, evidence: 'Private search query and candidate context', applicationIds: [] }, env);
527
+ assert.equal(report.code, 0, report.stderr);
528
+ const event = captured.find((body) => body.event === 'source_checked');
529
+ assert.ok(event, JSON.stringify(captured));
530
+ assert.deepEqual(event.properties, { sourceId: 'community', status: 'searched', reviewedCount: 8, qualifiedCount: 2 });
531
+ assert.equal(JSON.stringify(captured).includes('Private search query'), false);
532
+ assert.equal(JSON.stringify(captured).includes(roundId), false);
533
+ assert.equal(JSON.stringify(captured).includes(sourceId), false);
534
+ });
535
+
536
+ test('CLI attaches only explicit saved name/email after disclosure and opt-out continues anonymous usage', async (t) => {
537
+ const directory = await mkdtemp(join(tmpdir(), 'job-agent-identity-cli-'));
538
+ t.after(() => rm(directory, { recursive: true, force: true }));
539
+ const captured = [];
540
+ const server = createServer(async (request, response) => {
541
+ let raw = '';
542
+ for await (const chunk of request) raw += chunk;
543
+ captured.push({ path: request.url, body: JSON.parse(raw) });
544
+ response.setHeader('content-type', 'application/json');
545
+ response.end(JSON.stringify(request.url === '/v1/install' ? { installationId: crypto.randomUUID(), token: 'synthetic-relay-token', expiresAt: '2099-01-01T00:00:00Z' } : { accepted: true }));
546
+ });
547
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
548
+ t.after(() => new Promise((resolve) => server.close(resolve)));
549
+ // Synthetic Secret Service implementation: never touch the developer's real profile.
550
+ const preload = `import cp from 'node:child_process'; import { syncBuiltinESMExports } from 'node:module'; Object.defineProperty(process, 'platform', { value: 'linux' }); cp.execFileSync = (file, args) => { if (file === 'secret-tool' && args[0] === 'lookup') return ${JSON.stringify(JSON.stringify(target))}; throw new Error('Unexpected secret operation'); }; syncBuiltinESMExports();`;
551
+ const runtimeArgs = ['--import', `data:text/javascript,${encodeURIComponent(preload)}`];
552
+ const env = { ...isolatedCliEnv(directory), JOB_APPLICATION_AGENT_TELEMETRY_URL: `http://127.0.0.1:${server.address().port}` };
553
+ const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
554
+ const run = (args) => runCli(script, args, undefined, env, runtimeArgs);
555
+ const first = await run(['profile', 'check']);
556
+ assert.equal(first.code, 0, first.stderr);
557
+ assert.match(first.stderr, /name and email sharing is enabled by default/i);
558
+ assert.ok(captured.length > 0);
559
+ assert.ok(captured.every(({ body }) => body.identity === undefined));
560
+ const second = await run(['profile', 'check']);
561
+ assert.equal(second.code, 0, second.stderr);
562
+ const identified = captured.at(-1).body;
563
+ assert.deepEqual(identified.identity, { name: target.name, email: target.email });
564
+ assert.equal(JSON.stringify(identified).includes(target.phone), false);
565
+ assert.equal(JSON.stringify(identified).includes(target.location), false);
566
+ const count = captured.length;
567
+ const optedOut = await run(['telemetry', 'identity', 'disable']);
568
+ assert.equal(optedOut.code, 0, optedOut.stderr);
569
+ assert.equal(captured.length, count);
570
+ assert.equal((await run(['profile', 'check'])).code, 0);
571
+ assert.equal(captured.at(-1).body.identity, undefined);
572
+ assert.notEqual(captured.at(-1).body.installationId, identified.installationId);
573
+ });
@@ -0,0 +1,77 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import test from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ import { createSecretStore, LINUX_SECRET_MAX_BYTES } from '../scripts/secret-store.mjs';
10
+
11
+ const enabled = process.platform === 'linux'
12
+ && process.env.JOB_APPLICATION_AGENT_LINUX_SECRET_SERVICE_TEST === '1';
13
+
14
+ test('Linux Secret Service supports the real profile CLI lifecycle', { skip: !enabled }, async (t) => {
15
+ const directory = await mkdtemp(join(tmpdir(), 'job-agent-linux-secret-service-'));
16
+ const service = `com.vaibhavarora.job-application-agent.test.${process.pid}.${Date.now()}`;
17
+ const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
18
+ const env = {
19
+ ...process.env,
20
+ JOB_APPLICATION_AGENT_KEYCHAIN_SERVICE: service,
21
+ JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_URL: 'http://127.0.0.1:9',
22
+ JOB_APPLICATION_AGENT_STATE_DIR: directory,
23
+ };
24
+ t.after(async () => {
25
+ execFileSync('secret-tool', ['clear', 'service', service, 'account', 'profile'], { stdio: 'ignore' });
26
+ await rm(directory, { recursive: true, force: true });
27
+ });
28
+ await writeFile(join(directory, 'telemetry.json'), JSON.stringify({
29
+ version: 1,
30
+ enabled: false,
31
+ disclosed: true,
32
+ graceConsumed: true,
33
+ installationEventPending: false,
34
+ }));
35
+
36
+ const store = createSecretStore({ platform: 'linux', env, service });
37
+ const first = JSON.stringify({ name: 'Secret Service integration' });
38
+ store.writeProfile(first);
39
+ assert.equal(store.readProfile(), first);
40
+ assert.throws(
41
+ () => store.writeProfile('x'.repeat(LINUX_SECRET_MAX_BYTES + 1)),
42
+ /too large for Linux Secret Service storage/,
43
+ );
44
+ assert.equal(store.readProfile(), first);
45
+
46
+ const profile = {
47
+ name: 'Linux Test Candidate',
48
+ email: 'candidate@example.com',
49
+ phone: '+1 555 0100',
50
+ location: 'Toronto, Canada',
51
+ workAuthorization: 'Canada',
52
+ roleFamilies: ['product-engineering'],
53
+ seniority: ['senior'],
54
+ skills: ['JavaScript'],
55
+ targetLocations: ['Canada'],
56
+ excludedLocations: [],
57
+ workModes: ['remote'],
58
+ industries: ['software'],
59
+ excludedCompanies: [],
60
+ submissionMode: 'review-each',
61
+ yearsExperience: 8,
62
+ autoSubmitMinScore: 80,
63
+ manualReviewMinScore: 70,
64
+ minMustHaveCoverage: 70,
65
+ };
66
+ const setResult = JSON.parse(execFileSync(process.execPath, [script, 'profile', 'set', '--stdin'], {
67
+ env,
68
+ input: JSON.stringify(profile),
69
+ encoding: 'utf8',
70
+ }));
71
+ const checkResult = JSON.parse(execFileSync(process.execPath, [script, 'profile', 'check'], { env, encoding: 'utf8' }));
72
+ const fieldResult = JSON.parse(execFileSync(process.execPath, [script, 'profile', 'field', 'name'], { env, encoding: 'utf8' }));
73
+
74
+ assert.equal(setResult.stored, true);
75
+ assert.equal(checkResult.configured, true);
76
+ assert.deepEqual(fieldResult, { name: profile.name });
77
+ });
@@ -8,10 +8,11 @@ import {
8
8
  createSecretStore,
9
9
  DEFAULT_SECRET_SERVICE,
10
10
  LEGACY_SECRET_SERVICE,
11
- LINUX_PROFILE_ERROR,
11
+ LINUX_SECRET_MAX_BYTES,
12
12
  migrateLegacyStateDir,
13
13
  PROFILE_ACCOUNT,
14
14
  resolveStateDir,
15
+ UNSUPPORTED_PLATFORM_ERROR,
15
16
  } from '../scripts/secret-store.mjs';
16
17
 
17
18
  const sampleProfile = {
@@ -132,8 +133,115 @@ test('windows store keeps a wrapping key small and a profile larger than 2560 by
132
133
  assert.ok(files.get(profileFile).length > 2560);
133
134
  });
134
135
 
135
- test('linux store rejects secure profile storage with a clear error', () => {
136
- const store = createSecretStore({ platform: 'linux' });
137
- assert.throws(() => store.readProfile(), { message: LINUX_PROFILE_ERROR });
138
- assert.throws(() => store.writeProfile('{}'), { message: LINUX_PROFILE_ERROR });
136
+ test('linux store writes and reads the profile via Secret Service', () => {
137
+ const secrets = new Map();
138
+ const exec = (command, args, options) => {
139
+ assert.equal(command, 'secret-tool');
140
+ if (args[0] === 'lookup') {
141
+ const service = args[args.indexOf('service') + 1];
142
+ const account = args[args.indexOf('account') + 1];
143
+ if (!secrets.has(`${service}/${account}`)) throw new Error('not found');
144
+ return `${secrets.get(`${service}/${account}`)}\n`;
145
+ }
146
+ if (args[0] === 'store') {
147
+ const service = args[args.indexOf('service') + 1];
148
+ const account = args[args.indexOf('account') + 1];
149
+ secrets.set(`${service}/${account}`, options.input);
150
+ return '';
151
+ }
152
+ throw new Error(`unexpected secret-tool args: ${args.join(' ')}`);
153
+ };
154
+ const store = createSecretStore({ platform: 'linux', execFileSync: exec });
155
+ store.writeProfile(JSON.stringify(sampleProfile));
156
+ assert.equal(store.readProfile(), JSON.stringify(sampleProfile));
157
+ });
158
+
159
+ test('linux store reports a clear error when secret-tool is not installed', () => {
160
+ const exec = () => { throw Object.assign(new Error('spawn secret-tool ENOENT'), { code: 'ENOENT' }); };
161
+ const store = createSecretStore({ platform: 'linux', execFileSync: exec });
162
+ assert.throws(() => store.writeProfile(JSON.stringify(sampleProfile)), /libsecret-tools/);
163
+ assert.throws(() => store.readProfile(), /libsecret-tools/);
164
+ });
165
+
166
+ test('linux store reports a clear error when keyring storage fails', () => {
167
+ const exec = (command, args) => {
168
+ assert.equal(command, 'secret-tool');
169
+ if (args[0] === 'lookup') throw new Error('not found');
170
+ throw new Error('keyring locked');
171
+ };
172
+ const store = createSecretStore({ platform: 'linux', execFileSync: exec });
173
+ assert.throws(() => store.writeProfile(JSON.stringify(sampleProfile)), /could not store the profile/);
174
+ assert.throws(() => store.readProfile(), /missing or unreadable/);
175
+ });
176
+
177
+ test('linux store treats a successful empty lookup as a missing profile', () => {
178
+ const store = createSecretStore({ platform: 'linux', execFileSync: () => '' });
179
+ assert.throws(() => store.readProfile(), /missing or unreadable/);
180
+ });
181
+
182
+ test('linux store rejects profiles that secret-tool would silently truncate', () => {
183
+ const stored = [];
184
+ const exec = (command, args, options) => {
185
+ assert.equal(command, 'secret-tool');
186
+ assert.equal(args[0], 'store');
187
+ stored.push(options.input);
188
+ return '';
189
+ };
190
+ const store = createSecretStore({ platform: 'linux', execFileSync: exec });
191
+
192
+ store.writeProfile('x'.repeat(LINUX_SECRET_MAX_BYTES));
193
+ assert.equal(stored[0].length, LINUX_SECRET_MAX_BYTES);
194
+ assert.throws(
195
+ () => store.writeProfile('x'.repeat(LINUX_SECRET_MAX_BYTES + 1)),
196
+ /too large for Linux Secret Service storage/,
197
+ );
198
+ assert.throws(
199
+ () => store.writeProfile('é'.repeat((LINUX_SECRET_MAX_BYTES + 1) / 2)),
200
+ /too large for Linux Secret Service storage/,
201
+ );
202
+ assert.equal(stored.length, 1);
203
+ });
204
+
205
+ test('linux store distinguishes an unavailable Secret Service from a missing profile', () => {
206
+ const exec = () => {
207
+ throw Object.assign(new Error('secret-tool exited with status 1'), {
208
+ stderr: 'secret-tool: Cannot autolaunch D-Bus without X11 $DISPLAY\n',
209
+ });
210
+ };
211
+ const store = createSecretStore({ platform: 'linux', execFileSync: exec });
212
+
213
+ assert.throws(() => store.readProfile(), /Secret Service could not read the profile/);
214
+ });
215
+
216
+ test('linux store keeps a prior profile readable when a later write fails', () => {
217
+ const secrets = new Map();
218
+ let failWrites = false;
219
+ const exec = (command, args, options) => {
220
+ assert.equal(command, 'secret-tool');
221
+ if (args[0] === 'lookup') {
222
+ const service = args[args.indexOf('service') + 1];
223
+ const account = args[args.indexOf('account') + 1];
224
+ if (!secrets.has(`${service}/${account}`)) throw new Error('not found');
225
+ return `${secrets.get(`${service}/${account}`)}\n`;
226
+ }
227
+ if (args[0] === 'store') {
228
+ if (failWrites) throw new Error('keyring locked');
229
+ const service = args[args.indexOf('service') + 1];
230
+ const account = args[args.indexOf('account') + 1];
231
+ secrets.set(`${service}/${account}`, options.input);
232
+ return '';
233
+ }
234
+ throw new Error(`unexpected secret-tool args: ${args.join(' ')}`);
235
+ };
236
+ const store = createSecretStore({ platform: 'linux', execFileSync: exec });
237
+ store.writeProfile(JSON.stringify(sampleProfile));
238
+ failWrites = true;
239
+ assert.throws(() => store.writeProfile(JSON.stringify({ ...sampleProfile, name: 'Overwrite' })), /could not store the profile/);
240
+ assert.equal(store.readProfile(), JSON.stringify(sampleProfile));
241
+ });
242
+
243
+ test('unsupported platforms retain an explicit profile storage error', () => {
244
+ const store = createSecretStore({ platform: 'freebsd' });
245
+ assert.throws(() => store.readProfile(), { message: UNSUPPORTED_PLATFORM_ERROR });
246
+ assert.throws(() => store.writeProfile('{}'), { message: UNSUPPORTED_PLATFORM_ERROR });
139
247
  });