job-application-agent 3.3.0 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- export const SKILL_VERSION = '3.3.0';
1
+ export const SKILL_VERSION = '3.4.1';
@@ -0,0 +1,105 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import { CloudStateClient, saveCloudConfig } from '../scripts/cloud-state-client.mjs';
8
+ import worker, { sha256Hex } from '../../state-worker/src/worker.mjs';
9
+ import { createMemoryD1, hasNodeSqlite } from '../../state-worker/tests/d1-mock.mjs';
10
+ import { createMemoryR2 } from '../../state-worker/tests/r2-mock.mjs';
11
+
12
+ const TOKEN = 'test-client-token-with-sufficient-length-cloud';
13
+ const sqliteTest = hasNodeSqlite ? test : test.skip;
14
+
15
+ async function setup() {
16
+ const root = await mkdtemp(join(tmpdir(), 'job-agent-cloud-'));
17
+ const stateDir = join(root, 'state');
18
+ const configPath = join(root, 'cloud', 'config.json');
19
+ await mkdir(stateDir, { recursive: true });
20
+ const schema = await readFile(new URL('../../state-worker/migrations/0001_private_state.sql', import.meta.url), 'utf8');
21
+ const DB = createMemoryD1(schema);
22
+ await DB.prepare('INSERT INTO clients (client_id, name, token_hash, created_at) VALUES (?, ?, ?, ?)')
23
+ .bind('client-test', 'Test Client', sha256Hex(TOKEN), new Date().toISOString()).run();
24
+ const bindings = { DB, STATE: createMemoryR2(), STATE_TOKEN: 'legacy', LEGACY_WRITES_DISABLED: '1' };
25
+ const fetchImpl = (input, init) => worker.fetch(new Request(input, init), bindings);
26
+ await saveCloudConfig({ version: 2, url: 'https://state.example.com', token: TOKEN, clientId: 'client-test', clientName: 'Test Client' }, { configPath });
27
+ const client = new CloudStateClient({ stateDir, configPath, fetchImpl });
28
+ return { root, stateDir, configPath, bindings, client };
29
+ }
30
+
31
+ sqliteTest('cloud config and profile cache are owner-only and work without Keychain', async () => {
32
+ const ctx = await setup();
33
+ await ctx.client.putDocument('profile', { name: 'Ada', email: 'ada@example.com' }, 0);
34
+ const profile = await ctx.client.refreshProfileCache();
35
+ assert.equal(profile.name, 'Ada');
36
+ if (process.platform !== 'win32') assert.equal((await stat(ctx.configPath)).mode & 0o777, 0o600);
37
+ const cache = join(ctx.stateDir, 'cloud-profile-cache.json');
38
+ if (process.platform !== 'win32') assert.equal((await stat(cache)).mode & 0o777, 0o600);
39
+ assert.deepEqual(JSON.parse(await readFile(cache, 'utf8')), profile);
40
+ });
41
+
42
+ sqliteTest('cloud documents refresh the owner-only local caches', async () => {
43
+ const ctx = await setup();
44
+ await ctx.client.putDocument('autonomy', { version: 1, enabled: true, mode: 'routine-auto' }, 0);
45
+
46
+ const refreshed = await ctx.client.refreshDocumentCaches();
47
+
48
+ assert.equal(refreshed.autonomy.revision, 1);
49
+ assert.equal(JSON.parse(await readFile(join(ctx.stateDir, 'autonomy.json'), 'utf8')).enabled, true);
50
+ if (process.platform !== 'win32') assert.equal((await stat(join(ctx.stateDir, 'autonomy.json'))).mode & 0o777, 0o600);
51
+ });
52
+
53
+ sqliteTest('reconcile dry-run reports the exact union without writing', async () => {
54
+ const ctx = await setup();
55
+ const local = [
56
+ { id: 'app-1', company: 'A', submittedAt: '2026-01-01T00:00:00.000Z' },
57
+ { id: 'app-2', company: 'B', submittedAt: '2026-01-02T00:00:00.000Z' },
58
+ ];
59
+ await writeFile(join(ctx.stateDir, 'applications.ndjson'), `${local.map(JSON.stringify).join('\n')}\n`);
60
+ await ctx.client.appendRecord('applications', local[0], { recordKey: 'app-1', idempotencyKey: 'migration:app-1' });
61
+ const report = await ctx.client.reconcile({ dryRun: true });
62
+ assert.deepEqual(report.streams.applications, { localRows: 2, cloudRows: 1, localOnly: 1, cloudOnly: 0, unionRows: 2 });
63
+ assert.equal((await ctx.bindings.DB.prepare("SELECT COUNT(*) AS count FROM records WHERE stream = 'applications'").first()).count, 1);
64
+ });
65
+
66
+ sqliteTest('reconcile imports local-only rows idempotently and preserves provenance', async () => {
67
+ const ctx = await setup();
68
+ const local = [{ id: 'app-1', company: 'A', submittedAt: '2026-01-01T00:00:00.000Z' }];
69
+ await writeFile(join(ctx.stateDir, 'applications.ndjson'), `${JSON.stringify(local[0])}\n`);
70
+ const first = await ctx.client.reconcile({ dryRun: false, provenance: 'mac-cutover' });
71
+ const second = await ctx.client.reconcile({ dryRun: false, provenance: 'mac-cutover' });
72
+ assert.equal(first.imported, 1);
73
+ assert.equal(second.imported, 0);
74
+ const row = await ctx.bindings.DB.prepare("SELECT provenance FROM records WHERE stream = 'applications'").first();
75
+ assert.equal(row.provenance, 'mac-cutover');
76
+ });
77
+
78
+ sqliteTest('reconcile includes the owner-only discovery review ledger', async () => {
79
+ const ctx = await setup();
80
+ const lead = { type: 'lead-reviewed', roundId: 'round-1', leadId: 'lead-1', disposition: 'duplicate', occurredAt: '2026-01-01T00:00:00.000Z' };
81
+ await writeFile(join(ctx.stateDir, 'discovery.ndjson'), `${JSON.stringify(lead)}\n`);
82
+
83
+ const report = await ctx.client.reconcile({ dryRun: false });
84
+
85
+ assert.equal(report.streams.discovery.imported ?? report.streams.discovery.localOnly, 1);
86
+ assert.deepEqual((await ctx.client.listStream('discovery'))[0].value, lead);
87
+ });
88
+
89
+ sqliteTest('resume download verifies checksum and creates a private path cache', async () => {
90
+ const ctx = await setup();
91
+ const bytes = Buffer.from('%PDF-1.7\ncloud-resume-fixture');
92
+ await ctx.client.putFile('resume.pdf', bytes, 0);
93
+ const result = await ctx.client.fetchResume();
94
+ assert.equal(result.sha256, sha256Hex(bytes));
95
+ if (process.platform !== 'win32') assert.equal((await stat(result.path)).mode & 0o777, 0o600);
96
+ assert.equal(Buffer.from(await readFile(result.path)).equals(bytes), true);
97
+ });
98
+
99
+ sqliteTest('outage queues an already observed append but blocks application intents', async () => {
100
+ const ctx = await setup();
101
+ const offline = new CloudStateClient({ stateDir: ctx.stateDir, configPath: ctx.configPath, fetchImpl: async () => { throw new Error('offline'); } });
102
+ const queued = await offline.appendRecord('outcomes', { id: 'app-1', status: 'interview' }, { recordKey: 'app-1', idempotencyKey: 'outcome:1', queueOnFailure: true });
103
+ assert.equal(queued.queued, true);
104
+ await assert.rejects(() => offline.createIntent({ applicationId: 'app-2', canonicalUrl: 'https://jobs.example/app-2', leaseId: 'lease' }), /cloud state unavailable/i);
105
+ });
@@ -5,6 +5,7 @@ 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
10
  import { buildReview, commandCategory, durationBucket, migrateProfile, profileStatus, scoreJob, telemetryErrorCode, telemetryJobAssessed, validateLedgerEntry, validateProfile, validateSubmissionTelemetry } from '../scripts/job-application.mjs';
10
11
 
@@ -52,6 +53,7 @@ function isolatedCliEnv(directory) {
52
53
  return {
53
54
  ...process.env,
54
55
  JOB_APPLICATION_AGENT_STATE_DIR: directory,
56
+ JOB_APPLICATION_AGENT_CLOUD_CONFIG: join(directory, 'cloud-config.json'),
55
57
  JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_URL: 'http://127.0.0.1:9',
56
58
  };
57
59
  }
@@ -88,6 +90,24 @@ test('returns the canonical resume path for direct browser uploads', async (t) =
88
90
  assert.deepEqual(result, { path: resume });
89
91
  });
90
92
 
93
+ test('returns a verified local resume cache while configured cloud storage is offline', async (t) => {
94
+ const directory = await mkdtemp(join(tmpdir(), 'public-job-agent-offline-resume-'));
95
+ const cloudDirectory = await mkdtemp(join(tmpdir(), 'public-job-agent-offline-cloud-'));
96
+ t.after(() => rm(directory, { recursive: true, force: true }));
97
+ t.after(() => rm(cloudDirectory, { recursive: true, force: true }));
98
+ const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
99
+ const resume = join(directory, 'resume.pdf');
100
+ const configPath = join(cloudDirectory, 'config.json');
101
+ await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false }));
102
+ await writeFile(resume, '%PDF-1.7\ncached resume fixture');
103
+ await writeFile(configPath, JSON.stringify({ version: 2, url: 'https://127.0.0.1:9', token: 'offline-client-token-with-sufficient-length', clientId: 'offline-client' }), { mode: 0o600 });
104
+ const env = { ...isolatedCliEnv(directory), JOB_APPLICATION_AGENT_CLOUD_CONFIG: configPath };
105
+
106
+ const result = JSON.parse(execFileSync(process.execPath, [script, 'resume', 'path'], { env, encoding: 'utf8' }));
107
+
108
+ assert.deepEqual(result, { path: resume });
109
+ });
110
+
91
111
  test('preserves the Linux secret-tool install error for profile-dependent commands', async (t) => {
92
112
  const directory = await mkdtemp(join(tmpdir(), 'job-agent-linux-profile-error-'));
93
113
  t.after(() => rm(directory, { recursive: true, force: true }));
@@ -492,5 +512,81 @@ test('telemetry CLI controls are private and reset removes anonymous credentials
492
512
  assert.equal(disabled.enabled, false);
493
513
  const reset = JSON.parse(execFileSync(process.execPath, [script, 'telemetry', 'reset'], { env, encoding: 'utf8' }));
494
514
  assert.equal(reset.hasInstallationId, false);
515
+ const identityDisabled = JSON.parse(execFileSync(process.execPath, [script, 'telemetry', 'identity', 'disable'], { env, encoding: 'utf8' }));
516
+ assert.equal(identityDisabled.identityEnabled, false);
517
+ assert.equal(identityDisabled.enabled, false);
518
+ const identityEnabled = JSON.parse(execFileSync(process.execPath, [script, 'telemetry', 'identity', 'enable'], { env, encoding: 'utf8' }));
519
+ assert.equal(identityEnabled.identityEnabled, true);
520
+ assert.equal(identityEnabled.enabled, false);
521
+ assert.equal(identityEnabled.identityDisclosed, false);
495
522
  if (process.platform !== 'win32') assert.equal((await stat(join(directory, 'telemetry.json'))).mode & 0o777, 0o600);
496
523
  });
524
+
525
+ test('CLI emits bounded source coverage without private evidence or attribution', async (t) => {
526
+ const directory = await mkdtemp(join(tmpdir(), 'job-agent-coverage-cli-'));
527
+ t.after(() => rm(directory, { recursive: true, force: true }));
528
+ const captured = [];
529
+ const server = createServer(async (request, response) => {
530
+ let raw = '';
531
+ for await (const chunk of request) raw += chunk;
532
+ captured.push(JSON.parse(raw));
533
+ response.setHeader('content-type', 'application/json');
534
+ response.end(JSON.stringify({ accepted: true }));
535
+ });
536
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
537
+ t.after(() => new Promise((resolve) => server.close(resolve)));
538
+ 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' }));
539
+ const env = { ...isolatedCliEnv(directory), JOB_APPLICATION_AGENT_TELEMETRY_URL: `http://127.0.0.1:${server.address().port}` };
540
+ const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
541
+ const started = await runCli(script, ['round', 'start', '--stdin'], { requestedCount: 1 }, env);
542
+ assert.equal(started.code, 0, started.stderr);
543
+ const roundId = JSON.parse(started.stdout).roundId;
544
+ const sourceId = 'community-0123456789abcdef';
545
+ 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);
546
+ assert.equal(report.code, 0, report.stderr);
547
+ const event = captured.find((body) => body.event === 'source_checked');
548
+ assert.ok(event, JSON.stringify(captured));
549
+ assert.deepEqual(event.properties, { sourceId: 'community', status: 'searched', reviewedCount: 8, qualifiedCount: 2 });
550
+ assert.equal(JSON.stringify(captured).includes('Private search query'), false);
551
+ assert.equal(JSON.stringify(captured).includes(roundId), false);
552
+ assert.equal(JSON.stringify(captured).includes(sourceId), false);
553
+ });
554
+
555
+ test('CLI attaches only explicit saved name/email after disclosure and opt-out continues anonymous usage', async (t) => {
556
+ const directory = await mkdtemp(join(tmpdir(), 'job-agent-identity-cli-'));
557
+ t.after(() => rm(directory, { recursive: true, force: true }));
558
+ const captured = [];
559
+ const server = createServer(async (request, response) => {
560
+ let raw = '';
561
+ for await (const chunk of request) raw += chunk;
562
+ captured.push({ path: request.url, body: JSON.parse(raw) });
563
+ response.setHeader('content-type', 'application/json');
564
+ response.end(JSON.stringify(request.url === '/v1/install' ? { installationId: crypto.randomUUID(), token: 'synthetic-relay-token', expiresAt: '2099-01-01T00:00:00Z' } : { accepted: true }));
565
+ });
566
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
567
+ t.after(() => new Promise((resolve) => server.close(resolve)));
568
+ // Synthetic Secret Service implementation: never touch the developer's real profile.
569
+ 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();`;
570
+ const runtimeArgs = ['--import', `data:text/javascript,${encodeURIComponent(preload)}`];
571
+ const env = { ...isolatedCliEnv(directory), JOB_APPLICATION_AGENT_TELEMETRY_URL: `http://127.0.0.1:${server.address().port}` };
572
+ const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
573
+ const run = (args) => runCli(script, args, undefined, env, runtimeArgs);
574
+ const first = await run(['profile', 'check']);
575
+ assert.equal(first.code, 0, first.stderr);
576
+ assert.match(first.stderr, /name and email sharing is enabled by default/i);
577
+ assert.ok(captured.length > 0);
578
+ assert.ok(captured.every(({ body }) => body.identity === undefined));
579
+ const second = await run(['profile', 'check']);
580
+ assert.equal(second.code, 0, second.stderr);
581
+ const identified = captured.at(-1).body;
582
+ assert.deepEqual(identified.identity, { name: target.name, email: target.email });
583
+ assert.equal(JSON.stringify(identified).includes(target.phone), false);
584
+ assert.equal(JSON.stringify(identified).includes(target.location), false);
585
+ const count = captured.length;
586
+ const optedOut = await run(['telemetry', 'identity', 'disable']);
587
+ assert.equal(optedOut.code, 0, optedOut.stderr);
588
+ assert.equal(captured.length, count);
589
+ assert.equal((await run(['profile', 'check'])).code, 0);
590
+ assert.equal(captured.at(-1).body.identity, undefined);
591
+ assert.notEqual(captured.at(-1).body.installationId, identified.installationId);
592
+ });
@@ -18,6 +18,7 @@ test('Linux Secret Service supports the real profile CLI lifecycle', { skip: !en
18
18
  const env = {
19
19
  ...process.env,
20
20
  JOB_APPLICATION_AGENT_KEYCHAIN_SERVICE: service,
21
+ JOB_APPLICATION_AGENT_CLOUD_CONFIG: join(directory, 'cloud-config.json'),
21
22
  JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_URL: 'http://127.0.0.1:9',
22
23
  JOB_APPLICATION_AGENT_STATE_DIR: directory,
23
24
  };
@@ -26,7 +26,7 @@ test('new installations disclose and send the first event immediately', async (t
26
26
  assert.equal(session.installationEventPending, true);
27
27
  await client.record({ event: 'installation_started', properties: { osFamily: 'macos', nodeMajor: 24, submissionMode: 'unconfigured' } }, session);
28
28
  const result = await client.record({ event: 'command_completed', properties: { command: 'search', result: 'success', durationBucket: '1-5s' } }, session);
29
- assert.match(notice, /anonymous usage analytics/i);
29
+ assert.match(notice, /usage analytics/i);
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);
@@ -68,7 +68,7 @@ test('a pre-disclosure telemetry config cannot send on its disclosure command',
68
68
  let notice = '';
69
69
  const client = new TelemetryClient({ stateDir: directory, endpoint: 'https://relay.example.com', fetch: relay.fetch, stderr: (value) => { notice += value; } });
70
70
  const session = await client.beginCommand('search');
71
- assert.match(notice, /anonymous usage analytics/i);
71
+ assert.match(notice, /usage analytics/i);
72
72
  assert.equal(session.allowSend, false);
73
73
  assert.equal(relay.requests.length, 0);
74
74
  });
@@ -130,3 +130,92 @@ test('strict record rejects invalid schema while automatic telemetry stays best
130
130
  await assert.rejects(() => client.record(invalid, session, { strict: true }), /durationBucket/i);
131
131
  assert.deepEqual(await client.record(invalid, session), { sent: false, reason: 'invalid' });
132
132
  });
133
+
134
+ const usageEvent = { event: 'command_completed', properties: { command: 'profile', result: 'success', durationBucket: 'under-1s' } };
135
+
136
+ test('identity sharing defaults on but waits until the command after disclosure, including upgrades', async (t) => {
137
+ for (const legacy of [false, true]) {
138
+ const directory = await mkdtemp(join(tmpdir(), 'telemetry-identity-'));
139
+ t.after(() => rm(directory, { recursive: true, force: true }));
140
+ if (legacy) await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: true, disclosed: true, graceConsumed: true }));
141
+ const relay = fakeRelay();
142
+ let notice = '';
143
+ const client = new TelemetryClient({ stateDir: directory, fetch: relay.fetch, stderr: (value) => { notice += value; }, readIdentity: () => ({ name: 'Test Candidate', email: 'candidate@example.com' }) });
144
+ const first = await client.beginCommand('profile');
145
+ await client.record(usageEvent, first);
146
+ assert.match(notice, /name and email/i);
147
+ assert.match(notice, /telemetry identity disable/);
148
+ assert.equal(relay.requests.at(-1).body.identity, undefined);
149
+ await client.record(usageEvent); // A missing session must not bypass the disclosure gate.
150
+ assert.equal(relay.requests.at(-1).body.identity, undefined);
151
+ await client.record(usageEvent, await client.beginCommand('profile'));
152
+ assert.deepEqual(relay.requests.at(-1).body.identity, { name: 'Test Candidate', email: 'candidate@example.com' });
153
+ assert.equal((await client.status()).identityEnabled, true);
154
+ assert.equal(JSON.stringify(await client.readConfig()).includes('candidate@example.com'), false);
155
+ }
156
+ });
157
+
158
+ test('identity opt-out rotates credentials and stops identity sharing even in an already started command', async (t) => {
159
+ const directory = await mkdtemp(join(tmpdir(), 'telemetry-identity-optout-'));
160
+ t.after(() => rm(directory, { recursive: true, force: true }));
161
+ const relay = fakeRelay();
162
+ const client = new TelemetryClient({ stateDir: directory, fetch: relay.fetch, stderr: () => {}, readIdentity: () => ({ name: 'Test Candidate', email: 'candidate@example.com' }) });
163
+ await client.beginCommand('profile');
164
+ const session = await client.beginCommand('profile');
165
+ await client.record(usageEvent, session);
166
+ assert.ok(relay.requests.at(-1).body.identity);
167
+ const before = relay.requests.length;
168
+ const status = await client.configureIdentity('disable');
169
+ assert.equal(status.enabled, true);
170
+ assert.equal(status.identityEnabled, false);
171
+ assert.equal(status.hasInstallationId, false);
172
+ assert.equal(relay.requests.length, before);
173
+ await client.record(usageEvent, session);
174
+ assert.equal(relay.requests.at(-1).body.identity, undefined);
175
+ assert.deepEqual(relay.requests.at(-2).body, {});
176
+ const anonymousId = (await client.status()).installationId;
177
+ await client.configureIdentity('disable');
178
+ assert.equal((await client.status()).installationId, anonymousId);
179
+ await client.configure('disable');
180
+ await client.configure('enable');
181
+ assert.equal((await client.status()).identityEnabled, false);
182
+ });
183
+
184
+ test('disabled telemetry and identity opt-out never read identity; malformed identity does not stop usage', async (t) => {
185
+ const directory = await mkdtemp(join(tmpdir(), 'telemetry-identity-failure-'));
186
+ t.after(() => rm(directory, { recursive: true, force: true }));
187
+ const relay = fakeRelay();
188
+ let reads = 0;
189
+ const client = new TelemetryClient({ stateDir: directory, fetch: relay.fetch, stderr: () => {}, readIdentity: () => { reads++; return { name: 'Test', email: 'bad', resume: 'private' }; } });
190
+ await client.configure('disable');
191
+ await client.record(usageEvent, await client.beginCommand('profile'));
192
+ assert.equal(reads, 0);
193
+ await client.configure('enable');
194
+ await client.beginCommand('profile');
195
+ assert.equal((await client.record(usageEvent, await client.beginCommand('profile'))).sent, true);
196
+ assert.equal(relay.requests.at(-1).body.identity, undefined);
197
+ await client.configureIdentity('disable');
198
+ const before = reads;
199
+ await client.record(usageEvent, await client.beginCommand('profile'));
200
+ assert.equal(reads, before);
201
+ });
202
+
203
+ test('identity re-enable discloses again and rotates the anonymous interval without enabling telemetry', async (t) => {
204
+ const directory = await mkdtemp(join(tmpdir(), 'telemetry-identity-resume-'));
205
+ t.after(() => rm(directory, { recursive: true, force: true }));
206
+ const relay = fakeRelay();
207
+ const client = new TelemetryClient({ stateDir: directory, fetch: relay.fetch, stderr: () => {}, readIdentity: () => ({ email: 'candidate@example.com' }) });
208
+ await client.configureIdentity('disable');
209
+ await client.record(usageEvent, await client.beginCommand('profile'));
210
+ assert.equal(relay.requests.at(-1).body.identity, undefined);
211
+ await client.configure('disable');
212
+ const status = await client.configureIdentity('enable');
213
+ assert.equal(status.enabled, false);
214
+ assert.equal(status.hasInstallationId, false);
215
+ assert.equal(status.identityDisclosed, false);
216
+ await client.configure('enable');
217
+ await client.record(usageEvent, await client.beginCommand('profile'));
218
+ assert.equal(relay.requests.at(-1).body.identity, undefined);
219
+ await client.record(usageEvent, await client.beginCommand('profile'));
220
+ assert.deepEqual(relay.requests.at(-1).body.identity, { email: 'candidate@example.com' });
221
+ });
@@ -6,8 +6,20 @@ import {
6
6
  createTelemetryEnvelope,
7
7
  jobIdentity,
8
8
  validateEvent,
9
+ validateTelemetryEnvelope,
9
10
  } from '../scripts/telemetry-schema.mjs';
10
11
 
12
+ test('identity envelope accepts only bounded name and email and keeps old anonymous envelopes compatible', () => {
13
+ const input = { installationId: '11111111-1111-4111-8111-111111111111', token: 'signed-token', skillVersion: '3.3.0', event: 'command_completed', properties: { command: 'profile', result: 'success', durationBucket: 'under-1s' } };
14
+ const envelope = createTelemetryEnvelope({ ...input, identity: { name: ' Test Candidate ', email: ' candidate@example.com ' } });
15
+ assert.deepEqual(envelope.identity, { name: 'Test Candidate', email: 'candidate@example.com' });
16
+ assert.deepEqual(validateTelemetryEnvelope(envelope), envelope);
17
+ assert.equal(validateTelemetryEnvelope(createTelemetryEnvelope(input)).identity, undefined);
18
+ for (const identity of [{}, { name: 'x', phone: 'private' }, { email: 'invalid' }, { name: 'x'.repeat(161) }, { email: 'a\nb@example.com' }, { name: 'Test\nPrivate' }, { name: 'Test', resume: 'private' }]) {
19
+ assert.throws(() => validateTelemetryEnvelope({ ...envelope, identity }), /identity/i);
20
+ }
21
+ });
22
+
11
23
  const baseJob = {
12
24
  company: 'Example AI',
13
25
  title: 'Staff Product Engineer',
@@ -16,6 +28,16 @@ const baseJob = {
16
28
  ats: 'greenhouse',
17
29
  };
18
30
 
31
+ test('source coverage telemetry excludes local evidence and community identifiers', () => {
32
+ const event = { event: 'source_checked', properties: { sourceId: 'linkedin-jobs-feed', status: 'searched', reviewedCount: 10, qualifiedCount: 2 } };
33
+ assert.equal(validateEvent(event).properties.sourceId, 'linkedin-jobs-feed');
34
+ assert.throws(() => validateEvent({ ...event, properties: { ...event.properties, evidence: 'private query and notes' } }), /unknown/i);
35
+ assert.throws(() => validateEvent({ ...event, properties: { ...event.properties, sourceId: 'community-abcdef1234567890' } }), /sourceId/i);
36
+ assert.throws(() => validateEvent({ ...event, properties: { ...event.properties, qualifiedCount: 11 } }), /qualifiedCount/i);
37
+ assert.throws(() => validateEvent({ ...event, properties: { ...event.properties, status: 'blocked' } }), /block/i);
38
+ assert.equal(validateEvent({ event: 'source_checked', properties: { sourceId: 'community', status: 'blocked', reviewedCount: 0, qualifiedCount: 0, blocker: 'captcha' } }).properties.blocker, 'captcha');
39
+ });
40
+
19
41
  test('canonicalizes job URLs and hashes the destination without query data', async () => {
20
42
  assert.equal(canonicalizeJobUrl('https://Jobs.Example.com/role/123/?utm_source=x#apply'), 'https://jobs.example.com/role/123');
21
43
  const first = await jobIdentity('https://jobs.example.com/role/123?ref=friend');
@@ -20,7 +20,7 @@ async function fixture(t, label) {
20
20
  graceConsumed: true,
21
21
  installationEventPending: false,
22
22
  }));
23
- return { directory, env: { ...process.env, JOB_APPLICATION_AGENT_STATE_DIR: directory, JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_URL: 'http://127.0.0.1:9' } };
23
+ return { directory, env: { ...process.env, JOB_APPLICATION_AGENT_STATE_DIR: directory, JOB_APPLICATION_AGENT_CLOUD_CONFIG: join(directory, 'cloud-config.json'), JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_URL: 'http://127.0.0.1:9' } };
24
24
  }
25
25
 
26
26
  function cli(env, args, input) {
@@ -121,15 +121,87 @@ test('counts only unique confirmed ledger submissions for an explicit round', as
121
121
  assert.equal(status.blockedCount, 1);
122
122
  assert.equal(status.completed, false);
123
123
 
124
- const completed = cli(env, ['round', 'complete', '--stdin'], { roundId: started.roundId });
124
+ for (const sourceId of ['linkedin-jobs-feed', 'indeed', 'hacker-news-who-is-hiring']) {
125
+ cli(env, ['round', 'source', '--stdin'], {
126
+ roundId: started.roundId, sourceId, status: 'searched', reviewedCount: 30, qualifiedCount: sourceId === 'linkedin-jobs-feed' ? 30 : 0,
127
+ evidence: 'Reviewed matching postings against the unchanged target; other leads did not qualify.',
128
+ applicationIds: sourceId === 'linkedin-jobs-feed' ? Array.from({ length: 30 }, (_, i) => `round-role-${i}`) : [],
129
+ });
130
+ }
131
+ const completed = cli(env, ['round', 'complete', '--stdin'], { roundId: started.roundId, concentrationReason: 'stronger-fit', concentrationEvidence: 'The other searched sources had no qualifying roles; all selected roles met the target.' });
125
132
  assert.equal(completed.completed, true);
126
133
  assert.equal(cli(env, ['round', 'status', started.roundId]).completed, true);
127
134
  const roundEvents = (await readFile(join(directory, 'rounds.ndjson'), 'utf8')).trim().split('\n').map(JSON.parse);
128
135
  assert.equal(roundEvents.filter((event) => event.type === 'submission-confirmed').length, 30);
129
- assert.equal(roundEvents.length, 32);
136
+ assert.equal(roundEvents.length, 35);
130
137
  if (process.platform !== 'win32') assert.equal((await stat(join(directory, 'rounds.ndjson'))).mode & 0o777, 0o600);
131
138
  });
132
139
 
140
+ test('round completion requires distinct source coverage and explains concentration without imposing submission quotas', async (t) => {
141
+ const { env } = await fixture(t, 'source-coverage');
142
+ const { roundId } = cli(env, ['round', 'start', '--stdin'], { requestedCount: 2 });
143
+ for (const i of [1, 2]) cli(env, ['ledger', 'add', '--stdin'], submission(i, roundId, { discoverySourceId: 'linkedin-jobs-feed' }));
144
+ assert.match(cliFailure(env, ['round', 'complete', '--stdin'], { roundId }).stderr, /3 distinct discovery sources/i);
145
+ const check = { roundId, sourceId: 'linkedin-jobs-feed', status: 'searched', reviewedCount: 10, qualifiedCount: 2, evidence: 'Reviewed ten relevant postings using the target constraints.' };
146
+ cli(env, ['round', 'source', '--stdin'], check);
147
+ cli(env, ['round', 'source', '--stdin'], check);
148
+ assert.equal(cli(env, ['round', 'status', roundId]).discovery.attemptedSourceCount, 1);
149
+ cli(env, ['round', 'source', '--stdin'], { ...check, sourceId: 'indeed', qualifiedCount: 0 });
150
+ cli(env, ['round', 'source', '--stdin'], { ...check, sourceId: 'hacker-news-who-is-hiring', status: 'blocked', reviewedCount: 0, qualifiedCount: 0, blocker: 'site-error' });
151
+ const status = cli(env, ['round', 'status', roundId]);
152
+ assert.equal(status.discovery.coverageSatisfied, true);
153
+ assert.equal(status.discovery.searchedSourceCount, 2);
154
+ assert.equal(status.discovery.blockedSourceCount, 1);
155
+ assert.equal(status.discovery.maxSourceSharePercent, 100);
156
+ assert.match(cliFailure(env, ['round', 'complete', '--stdin'], { roundId }).stderr, /concentration/i);
157
+ const completed = cli(env, ['round', 'complete', '--stdin'], { roundId, concentrationReason: 'stronger-fit', concentrationEvidence: 'Only LinkedIn produced qualifying roles; Indeed had none and the third source was unavailable.' });
158
+ assert.equal(completed.completed, true);
159
+ assert.equal(cli(env, ['round', 'status', roundId]).discovery.concentrationReason, 'stronger-fit');
160
+ assert.match(cliFailure(env, ['round', 'source', '--stdin'], check).stderr, /completed round/i);
161
+ });
162
+
163
+ test('discovery coverage rejects invalid reports and requires attribution without rewriting confirmed ledger rows', async (t) => {
164
+ const { env, directory } = await fixture(t, 'source-validation');
165
+ const { roundId } = cli(env, ['round', 'start', '--stdin'], { requestedCount: 1 });
166
+ cli(env, ['ledger', 'add', '--stdin'], submission(1, roundId));
167
+ const before = await readFile(join(directory, 'applications.ndjson'), 'utf8');
168
+ const check = { roundId, sourceId: 'linkedin-jobs-feed', status: 'searched', reviewedCount: 4, qualifiedCount: 1, evidence: 'Checked relevant listings against the candidate target.' };
169
+ for (const bad of [{ sourceId: 'imaginary-board' }, { qualifiedCount: 5 }, { status: 'blocked' }, { applicationIds: ['not-in-round'] }, { evidence: '' }, { privateProfile: 'forbidden' }]) {
170
+ assert.equal(cliFailure(env, ['round', 'source', '--stdin'], { ...check, ...bad }).status, 1);
171
+ }
172
+ for (const sourceId of ['linkedin-jobs-feed', 'indeed', 'hacker-news-who-is-hiring']) cli(env, ['round', 'source', '--stdin'], { ...check, sourceId });
173
+ assert.match(cliFailure(env, ['round', 'complete', '--stdin'], { roundId }).stderr, /attribution/i);
174
+ cli(env, ['round', 'source', '--stdin'], { ...check, applicationIds: ['round-role-1'] });
175
+ assert.equal(cli(env, ['round', 'status', roundId]).discovery.unattributedCount, 0);
176
+ assert.equal(await readFile(join(directory, 'applications.ndjson'), 'utf8'), before);
177
+ });
178
+
179
+ test('source diversity is independent of ATS and concentration explanations are unnecessary for a balanced mix', async (t) => {
180
+ const { env } = await fixture(t, 'balanced-discovery');
181
+ const { roundId } = cli(env, ['round', 'start', '--stdin'], { requestedCount: 5 });
182
+ const ids = ['linkedin-jobs-feed', 'indeed', 'hacker-news-who-is-hiring'];
183
+ for (const sourceId of ids) cli(env, ['round', 'source', '--stdin'], { roundId, sourceId, status: 'searched', reviewedCount: 3, qualifiedCount: 2, evidence: 'Reviewed relevant postings and verified target fit.' });
184
+ for (let i = 1; i <= 5; i++) cli(env, ['ledger', 'add', '--stdin'], submission(i, roundId, { discoverySourceId: ids[(i - 1) % 3], applicationChannel: 'ashby' }));
185
+ const completed = cli(env, ['round', 'complete', '--stdin'], { roundId });
186
+ assert.equal(completed.discovery.maxSourceSharePercent, 40);
187
+ assert.equal(completed.discovery.concentrationNeedsExplanation, false);
188
+ assert.equal(cli(env, ['round', 'complete', '--stdin'], { roundId }).completionRecorded, false);
189
+ });
190
+
191
+ test('blocked-only attempts and two views of the same network do not satisfy discovery', async (t) => {
192
+ const { env } = await fixture(t, 'blocked-discovery');
193
+ const { roundId } = cli(env, ['round', 'start', '--stdin'], { requestedCount: 1 });
194
+ const check = { roundId, status: 'blocked', reviewedCount: 0, qualifiedCount: 0, blocker: 'login', evidence: 'Source requires an authenticated session that is unavailable.' };
195
+ for (const sourceId of ['linkedin-jobs-feed', 'yc-work-at-a-startup', 'yc-company-directory']) cli(env, ['round', 'source', '--stdin'], { ...check, sourceId });
196
+ assert.equal(cli(env, ['round', 'status', roundId]).discovery.attemptedSourceCount, 2);
197
+ cli(env, ['round', 'source', '--stdin'], { ...check, sourceId: 'indeed' });
198
+ assert.equal(cli(env, ['round', 'status', roundId]).discovery.coverageSatisfied, false);
199
+ const { blocker, ...searched } = check;
200
+ cli(env, ['round', 'source', '--stdin'], { ...searched, sourceId: 'indeed', status: 'searched', reviewedCount: 1, qualifiedCount: 1 });
201
+ cli(env, ['round', 'source', '--stdin'], { ...check, sourceId: 'indeed' });
202
+ assert.equal(cli(env, ['round', 'status', roundId]).discovery.coverageSatisfied, true);
203
+ });
204
+
133
205
  test('ships a filterable global discovery source catalog and tracks source attribution', async (t) => {
134
206
  const { directory, env } = await fixture(t, 'source-catalog');
135
207
  const catalog = cli(env, ['sources', 'list']);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-application-agent",
3
- "version": "3.3.0",
3
+ "version": "3.4.1",
4
4
  "description": "A privacy-first Agent Skill and CLI for evidence-based job discovery, application completion, and outcome tracking.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,7 +38,7 @@
38
38
  "check:native-artifacts": "node scripts/ci/validate-native-artifacts.mjs",
39
39
  "privacy-audit": "node --test job-application-agent/tests/privacy-audit.test.mjs",
40
40
  "smoke:package": "node scripts/smoke-package.mjs",
41
- "check": "node --check job-application-agent/scripts/job-application.mjs && node --check job-application-agent/scripts/secret-store.mjs && node --check job-application-agent/scripts/source-community-client.mjs && node --check job-application-agent/scripts/source-community-schema.mjs && node --check job-application-agent/scripts/telemetry-client.mjs && node --check job-application-agent/scripts/telemetry-schema.mjs && node --check job-application-agent/scripts/version.mjs && node --check telemetry-worker/src/worker.mjs && node --check telemetry-worker/src/public-stats.mjs && node --check telemetry-worker/public/dashboard.js && node --check installer/src/cli.mjs && node --check installer/src/installer.mjs && node --check installer/src/runner.mjs && node --check installer/src/scheduler.mjs && node --check scripts/ci/classify-paths.mjs && node --check scripts/ci/validate-native-artifacts.mjs && node --check bin/job-application-agent.mjs",
41
+ "check": "node --check job-application-agent/scripts/job-application.mjs && node --check job-application-agent/scripts/cloud-state-client.mjs && node --check job-application-agent/scripts/secret-store.mjs && node --check job-application-agent/scripts/source-community-client.mjs && node --check job-application-agent/scripts/source-community-schema.mjs && node --check job-application-agent/scripts/telemetry-client.mjs && node --check job-application-agent/scripts/telemetry-schema.mjs && node --check job-application-agent/scripts/version.mjs && node --check telemetry-worker/src/worker.mjs && node --check telemetry-worker/src/public-stats.mjs && node --check telemetry-worker/public/dashboard.js && node --check installer/src/cli.mjs && node --check installer/src/installer.mjs && node --check installer/src/runner.mjs && node --check installer/src/scheduler.mjs && node --check scripts/ci/classify-paths.mjs && node --check scripts/ci/validate-native-artifacts.mjs && node --check bin/job-application-agent.mjs && node --check state-worker/src/worker.mjs && node --check state-worker/src/backup.mjs && node --check state-worker/src/cli.mjs && node --check state-worker/bin/sync.mjs",
42
42
  "prepack": "npm run check && npm test && npm run privacy-audit"
43
43
  },
44
44
  "engines": {