job-application-agent 3.2.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/job-application-agent/SKILL.md +1 -1
- package/job-application-agent/scripts/job-application.mjs +4 -4
- package/job-application-agent/scripts/secret-store.mjs +54 -3
- package/job-application-agent/scripts/source-community-schema.mjs +6 -0
- package/job-application-agent/scripts/version.mjs +1 -1
- package/job-application-agent/tests/job-application.test.mjs +56 -9
- package/job-application-agent/tests/linux-secret-service.integration.test.mjs +77 -0
- package/job-application-agent/tests/secret-store.test.mjs +113 -5
- package/job-application-agent/tests/source-community-schema.test.mjs +2 -1
- package/job-application-agent/tests/workflow-state.test.mjs +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,6 +45,10 @@ record outcome Company — Senior Engineer — interview
|
|
|
45
45
|
|
|
46
46
|
Requires Node.js 20 or newer and a browser-capable coding agent.
|
|
47
47
|
|
|
48
|
+
On Linux, profile storage uses the Secret Service via the `secret-tool` CLI. Install it with `sudo apt-get install libsecret-tools` (Debian/Ubuntu) or the equivalent package manager command for your distribution.
|
|
49
|
+
|
|
50
|
+
Unlike macOS Keychain or Windows Credential Manager, the Linux Secret Service has no always-running system daemon: a keyring daemon (GNOME Keyring, KWallet, or similar) must be running in the user session for `secret-tool` to store or read the profile. On a desktop login this is normally already the case; on headless servers, containers, or SSH-only sessions, start one explicitly (e.g. `gnome-keyring-daemon --unlock --components=secrets`) before first use.
|
|
51
|
+
|
|
48
52
|
## ✨ What it does
|
|
49
53
|
|
|
50
54
|
| Stage | Behavior |
|
|
@@ -108,7 +112,7 @@ Discovery combines the reviewed [`SOURCES.json`](job-application-agent/reference
|
|
|
108
112
|
|
|
109
113
|
| Data | Where it stays |
|
|
110
114
|
|---|---|
|
|
111
|
-
| Profile | macOS Keychain
|
|
115
|
+
| Profile | macOS Keychain, Windows Credential Manager, or Linux Secret Service (libsecret) |
|
|
112
116
|
| Résumé and ledgers | Owner-only local state directory |
|
|
113
117
|
| Browser login | Existing browser session |
|
|
114
118
|
| Community-sharing preference and delivery receipts | Owner-only local state directory |
|
|
@@ -20,7 +20,7 @@ Use `scripts/job-application.mjs` for private state and deterministic checks. Re
|
|
|
20
20
|
1. Ask for a local PDF or read-only Google Docs resume URL. Import it without modifying the source.
|
|
21
21
|
2. Run `profile check`. If it reports missing or legacy fields, collect only facts that cannot be preserved or defaulted, then run `profile migrate --stdin`. Use `profile set --stdin` for a new profile.
|
|
22
22
|
3. Preserve identity fields during migration. Map legacy `salaryPreference` to `targetCompensation`. Add `compensationFloor` only when the candidate provides an amount, currency, and annual comparison basis.
|
|
23
|
-
4. Store the profile in OS-backed profile storage (macOS Keychain,
|
|
23
|
+
4. Store the profile in OS-backed profile storage (macOS Keychain, Windows Credential Manager with a DPAPI-protected local file, or Linux Secret Service via `secret-tool`). Store the canonical resume and append-only ledgers in the owner-only state directory.
|
|
24
24
|
5. Use `review-each` for per-application approval. Use `routine-auto` only when the current request authorizes the destination or batch and every automatic-eligibility condition passes.
|
|
25
25
|
6. When the candidate explicitly grants continuing autonomy, read [references/AUTONOMY.md](references/AUTONOMY.md) and persist it with `autonomy grant --stdin`. Do not repeat skill-level upload or submission approval prompts while the active grant and profile both use `routine-auto`.
|
|
26
26
|
7. Obey browser and tool confirmation requirements regardless of the stored mode or autonomy grant.
|
|
@@ -98,9 +98,9 @@ function telemetryStage(command) {
|
|
|
98
98
|
return ({ search: 'discovery', assess: 'assessment', apply: 'submission', outcome: 'outcome', review: 'review', resume: 'resume', profile: 'contact', onboard: 'contact' })[command] ?? 'application';
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
function telemetryErrorCode(error) {
|
|
101
|
+
export function telemetryErrorCode(error) {
|
|
102
102
|
const message = String(error?.message ?? '');
|
|
103
|
-
if (/keychain|credential manager|dpapi|authentication|login/i.test(message)) return 'authentication_required';
|
|
103
|
+
if (/keychain|keyring|secret service|secret-tool|credential manager|dpapi|authentication|login/i.test(message)) return 'authentication_required';
|
|
104
104
|
if (/network|fetch|http/i.test(message)) return 'network_failure';
|
|
105
105
|
if (/invalid|must|required|expected|unsupported/i.test(message)) return 'invalid_input';
|
|
106
106
|
return 'internal_error';
|
|
@@ -601,14 +601,14 @@ export function buildReview(entries, outcomeEntries = [], acknowledgements = [],
|
|
|
601
601
|
|
|
602
602
|
function storedProfileRaw() {
|
|
603
603
|
try { return object(JSON.parse(secretStore.readProfile()), 'profile'); } catch (error) {
|
|
604
|
-
if (/missing or unreadable|
|
|
604
|
+
if (/missing or unreadable|could not read|could not store|Windows profile storage|not supported on this platform|secret-tool is not installed/i.test(error.message)) throw error;
|
|
605
605
|
throw new Error('The stored profile is missing or unreadable. Run profile set again.');
|
|
606
606
|
}
|
|
607
607
|
}
|
|
608
608
|
|
|
609
609
|
function storedProfile() {
|
|
610
610
|
try { return validateProfile(storedProfileRaw()); } catch (error) {
|
|
611
|
-
if (/
|
|
611
|
+
if (/Secret Service could not read|not supported on this platform|secret-tool is not installed/i.test(error.message)) throw error;
|
|
612
612
|
throw new Error('The stored profile needs migration. Run profile check, then profile migrate --stdin.');
|
|
613
613
|
}
|
|
614
614
|
}
|
|
@@ -7,7 +7,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
export const PROFILE_ACCOUNT = 'profile';
|
|
8
8
|
export const DEFAULT_SECRET_SERVICE = 'com.vaibhavarora.job-application-agent';
|
|
9
9
|
export const LEGACY_SECRET_SERVICE = 'com.openai.codex.job-application-agent';
|
|
10
|
-
export const
|
|
10
|
+
export const UNSUPPORTED_PLATFORM_ERROR = 'Secure profile storage is not supported on this platform.';
|
|
11
|
+
export const LINUX_STORE_REQUIRED_TOOL = 'secret-tool';
|
|
12
|
+
export const LINUX_SECRET_MAX_BYTES = 8191;
|
|
11
13
|
export const WINDOWS_PROFILE_SCRIPT = fileURLToPath(new URL('./windows-profile-store.ps1', import.meta.url));
|
|
12
14
|
|
|
13
15
|
export function resolveSecretService(env = process.env) {
|
|
@@ -99,6 +101,54 @@ function createDarwinStore({ execFileSync: exec, service, legacyService, account
|
|
|
99
101
|
};
|
|
100
102
|
}
|
|
101
103
|
|
|
104
|
+
function linuxToolMissing(error) {
|
|
105
|
+
return error?.code === 'ENOENT';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function linuxFind(exec, service, account) {
|
|
109
|
+
try {
|
|
110
|
+
return exec(LINUX_STORE_REQUIRED_TOOL, ['lookup', 'service', service, 'account', account], {
|
|
111
|
+
encoding: 'utf8',
|
|
112
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
113
|
+
});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (linuxToolMissing(error)) throw new Error(`${LINUX_STORE_REQUIRED_TOOL} is not installed. Install libsecret-tools (e.g. sudo apt-get install libsecret-tools) to enable Linux profile storage.`);
|
|
116
|
+
if (String(error?.stderr ?? '').trim()) {
|
|
117
|
+
throw new Error('Secret Service could not read the profile. Start or unlock your keyring and retry; no profile data was logged.');
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function linuxWrite(exec, service, account, raw) {
|
|
124
|
+
if (Buffer.byteLength(raw, 'utf8') > LINUX_SECRET_MAX_BYTES) {
|
|
125
|
+
throw new Error(`The profile is too large for Linux Secret Service storage. Keep it under ${LINUX_SECRET_MAX_BYTES + 1} UTF-8 bytes and retry; the stored profile was not changed.`);
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
exec(LINUX_STORE_REQUIRED_TOOL, ['store', '--label=job-application-agent', 'service', service, 'account', account], {
|
|
129
|
+
input: raw,
|
|
130
|
+
encoding: 'utf8',
|
|
131
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
132
|
+
});
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (linuxToolMissing(error)) throw new Error(`${LINUX_STORE_REQUIRED_TOOL} is not installed. Install libsecret-tools (e.g. sudo apt-get install libsecret-tools) to enable Linux profile storage.`);
|
|
135
|
+
throw new Error('Secret Service could not store the profile. Unlock your keyring and retry; no profile data was logged.');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function createLinuxStore({ execFileSync: exec, service, account }) {
|
|
140
|
+
return {
|
|
141
|
+
readProfile() {
|
|
142
|
+
const current = linuxFind(exec, service, account);
|
|
143
|
+
if (current != null && current.trim()) return decodeSecret(current.trim());
|
|
144
|
+
throw new Error('The stored profile is missing or unreadable. Run profile set again.');
|
|
145
|
+
},
|
|
146
|
+
writeProfile(raw) {
|
|
147
|
+
linuxWrite(exec, service, account, raw);
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
102
152
|
function windowsRequest(exec, scriptPath, payload, plaintext = '') {
|
|
103
153
|
const input = `${JSON.stringify(payload)}\n${plaintext}`;
|
|
104
154
|
try {
|
|
@@ -143,8 +193,8 @@ function createWin32Store({
|
|
|
143
193
|
|
|
144
194
|
function unsupportedStore() {
|
|
145
195
|
return {
|
|
146
|
-
readProfile() { throw new Error(
|
|
147
|
-
writeProfile() { throw new Error(
|
|
196
|
+
readProfile() { throw new Error(UNSUPPORTED_PLATFORM_ERROR); },
|
|
197
|
+
writeProfile() { throw new Error(UNSUPPORTED_PLATFORM_ERROR); },
|
|
148
198
|
};
|
|
149
199
|
}
|
|
150
200
|
|
|
@@ -160,5 +210,6 @@ export function createSecretStore({
|
|
|
160
210
|
} = {}) {
|
|
161
211
|
if (platform === 'darwin') return createDarwinStore({ execFileSync: exec, service, legacyService, account });
|
|
162
212
|
if (platform === 'win32') return createWin32Store({ execFileSync: exec, service, account, stateDir, scriptPath });
|
|
213
|
+
if (platform === 'linux') return createLinuxStore({ execFileSync: exec, service, account });
|
|
163
214
|
return unsupportedStore();
|
|
164
215
|
}
|
|
@@ -74,6 +74,11 @@ function isPublicHostname(hostname) {
|
|
|
74
74
|
return value.includes('.');
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
function isReservedExampleHostname(hostname) {
|
|
78
|
+
const value = hostname.toLowerCase();
|
|
79
|
+
return ['example.com', 'example.net', 'example.org'].some((suffix) => value === suffix || value.endsWith(`.${suffix}`));
|
|
80
|
+
}
|
|
81
|
+
|
|
77
82
|
function hostnameMatches(hostname, suffix) {
|
|
78
83
|
return hostname === suffix || hostname.endsWith(`.${suffix}`);
|
|
79
84
|
}
|
|
@@ -139,6 +144,7 @@ export function normalizeCommunityJob(input) {
|
|
|
139
144
|
if (url.protocol !== 'https:' || url.username || url.password) throw new Error('community job.url must be a public HTTPS URL.');
|
|
140
145
|
url.hostname = url.hostname.replace(/\.+$/, '').toLowerCase();
|
|
141
146
|
if (!isPublicHostname(url.hostname)) throw new Error('community job.url must use a public HTTPS hostname.');
|
|
147
|
+
if (isReservedExampleHostname(url.hostname)) throw new Error('community job.url must not use a reserved example hostname.');
|
|
142
148
|
const pathname = decodedPathname(url.pathname).normalize('NFKC');
|
|
143
149
|
url.pathname = pathname;
|
|
144
150
|
if (containsEmailLike(pathname) || containsPhoneLikeLocation(url.hostname, pathname) || looksIdentityPath(pathname) || looksPersonal(url)) throw new Error('community job.url must not be a personal URL.');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SKILL_VERSION = '3.
|
|
1
|
+
export const SKILL_VERSION = '3.3.0';
|
|
@@ -6,7 +6,7 @@ import { join } from 'node:path';
|
|
|
6
6
|
import test from 'node:test';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
|
|
9
|
-
import { buildReview, commandCategory, durationBucket, migrateProfile, profileStatus, scoreJob, telemetryJobAssessed, validateLedgerEntry, validateProfile, validateSubmissionTelemetry } from '../scripts/job-application.mjs';
|
|
9
|
+
import { buildReview, commandCategory, durationBucket, migrateProfile, profileStatus, scoreJob, telemetryErrorCode, telemetryJobAssessed, validateLedgerEntry, validateProfile, validateSubmissionTelemetry } from '../scripts/job-application.mjs';
|
|
10
10
|
|
|
11
11
|
const target = {
|
|
12
12
|
name: 'Test Candidate',
|
|
@@ -48,9 +48,17 @@ const matchingJob = {
|
|
|
48
48
|
],
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
-
function
|
|
51
|
+
function isolatedCliEnv(directory) {
|
|
52
|
+
return {
|
|
53
|
+
...process.env,
|
|
54
|
+
JOB_APPLICATION_AGENT_STATE_DIR: directory,
|
|
55
|
+
JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_URL: 'http://127.0.0.1:9',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function runCli(script, args, input, env, runtimeArgs = []) {
|
|
52
60
|
return new Promise((resolve) => {
|
|
53
|
-
const child = spawn(process.execPath, [script, ...args], { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
61
|
+
const child = spawn(process.execPath, [...runtimeArgs, script, ...args], { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
54
62
|
let stdout = '';
|
|
55
63
|
let stderr = '';
|
|
56
64
|
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
@@ -73,13 +81,50 @@ test('returns the canonical resume path for direct browser uploads', async (t) =
|
|
|
73
81
|
const resume = join(directory, 'resume.pdf');
|
|
74
82
|
await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false }));
|
|
75
83
|
await writeFile(resume, '%PDF-1.7\ncanonical resume fixture');
|
|
76
|
-
const env =
|
|
84
|
+
const env = isolatedCliEnv(directory);
|
|
77
85
|
|
|
78
86
|
const result = JSON.parse(execFileSync(process.execPath, [script, 'resume', 'path'], { env, encoding: 'utf8' }));
|
|
79
87
|
|
|
80
88
|
assert.deepEqual(result, { path: resume });
|
|
81
89
|
});
|
|
82
90
|
|
|
91
|
+
test('preserves the Linux secret-tool install error for profile-dependent commands', async (t) => {
|
|
92
|
+
const directory = await mkdtemp(join(tmpdir(), 'job-agent-linux-profile-error-'));
|
|
93
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
94
|
+
const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
|
|
95
|
+
await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false }));
|
|
96
|
+
const platformOverride = `data:text/javascript,${encodeURIComponent("Object.defineProperty(process, 'platform', { value: 'linux' });")}`;
|
|
97
|
+
const env = { ...isolatedCliEnv(directory), PATH: '' };
|
|
98
|
+
|
|
99
|
+
const result = await runCli(script, ['profile', 'field', 'name'], undefined, env, ['--import', platformOverride]);
|
|
100
|
+
|
|
101
|
+
assert.equal(result.code, 1);
|
|
102
|
+
assert.match(result.stderr, /secret-tool is not installed/);
|
|
103
|
+
assert.doesNotMatch(result.stderr, /profile needs migration/i);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('preserves an unavailable Linux Secret Service error for profile-dependent commands', {
|
|
107
|
+
skip: process.platform === 'win32',
|
|
108
|
+
}, async (t) => {
|
|
109
|
+
const directory = await mkdtemp(join(tmpdir(), 'job-agent-linux-service-error-'));
|
|
110
|
+
const toolDirectory = await mkdtemp(join(tmpdir(), 'job-agent-secret-tool-'));
|
|
111
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
112
|
+
t.after(() => rm(toolDirectory, { recursive: true, force: true }));
|
|
113
|
+
const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
|
|
114
|
+
const toolPath = join(toolDirectory, 'secret-tool');
|
|
115
|
+
const toolSource = '#!/bin/sh\nprintf "%s\\n" "secret-tool: Secret Service is unavailable" >&2\nexit 1\n';
|
|
116
|
+
await writeFile(toolPath, toolSource, { mode: 0o755 });
|
|
117
|
+
await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false }));
|
|
118
|
+
const platformOverride = `data:text/javascript,${encodeURIComponent("Object.defineProperty(process, 'platform', { value: 'linux' });")}`;
|
|
119
|
+
const env = { ...isolatedCliEnv(directory), PATH: toolDirectory };
|
|
120
|
+
|
|
121
|
+
const result = await runCli(script, ['profile', 'field', 'name'], undefined, env, ['--import', platformOverride]);
|
|
122
|
+
|
|
123
|
+
assert.equal(result.code, 1);
|
|
124
|
+
assert.match(result.stderr, /Secret Service could not read the profile/);
|
|
125
|
+
assert.doesNotMatch(result.stderr, /profile needs migration/i);
|
|
126
|
+
});
|
|
127
|
+
|
|
83
128
|
test('migrates a legacy profile without discarding identity or salary preference', () => {
|
|
84
129
|
const legacy = {
|
|
85
130
|
name: 'Test Candidate', email: 'candidate@example.com', phone: '+1 555 0100',
|
|
@@ -246,7 +291,7 @@ test('deduplicates ledger entries by normalized URL', async (t) => {
|
|
|
246
291
|
telemetry: { durationBucket: '5-15m', fieldsFilled: 14, shortAnswerCount: 2, resumeUploaded: true },
|
|
247
292
|
};
|
|
248
293
|
await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false }));
|
|
249
|
-
const env =
|
|
294
|
+
const env = isolatedCliEnv(directory);
|
|
250
295
|
execFileSync(process.execPath, [script, 'ledger', 'add', '--stdin'], { input: JSON.stringify(entry), env, encoding: 'utf8' });
|
|
251
296
|
const duplicate = JSON.parse(execFileSync(process.execPath, [script, 'ledger', 'check', '--stdin'], { input: JSON.stringify({ id: 'different', url: 'https://jobs.example.com/123?ref=friend' }), env, encoding: 'utf8' }));
|
|
252
297
|
assert.equal(duplicate.duplicate, true);
|
|
@@ -263,7 +308,7 @@ test('warns on same-company role matches and serializes concurrent duplicate sub
|
|
|
263
308
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
264
309
|
const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
|
|
265
310
|
await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false }));
|
|
266
|
-
const env =
|
|
311
|
+
const env = isolatedCliEnv(directory);
|
|
267
312
|
const base = {
|
|
268
313
|
company: 'Example', role: 'Senior Product Engineer', url: 'https://jobs.example.com/123', source: 'company', score: 88,
|
|
269
314
|
status: 'submitted', submittedAt: '2026-01-15T10:00:00Z', approval: 'STANDING AUTHORIZATION', answers: {}, employerJobId: 'example:123',
|
|
@@ -291,7 +336,7 @@ test('records structured outcomes idempotently without duplicate rows', async (t
|
|
|
291
336
|
id: 'example-role-1', company: 'Example', role: 'Senior Product Engineer', url: 'https://jobs.example.com/123', source: 'company', score: 88,
|
|
292
337
|
status: 'submitted', submittedAt: '2026-01-15T10:00:00Z', approval: 'STANDING AUTHORIZATION', answers: {},
|
|
293
338
|
})}\n`);
|
|
294
|
-
const env =
|
|
339
|
+
const env = isolatedCliEnv(directory);
|
|
295
340
|
const outcome = { id: 'example-role-1', status: 'rejected', occurredAt: '2026-01-20T09:00:00Z', note: 'No sponsorship' };
|
|
296
341
|
const enriched = { ...outcome, reasons: [{ category: 'eligibility', evidence: 'explicit' }] };
|
|
297
342
|
const first = JSON.parse(execFileSync(process.execPath, [script, 'ledger', 'outcome', '--stdin'], { input: JSON.stringify(outcome), env, encoding: 'utf8' }));
|
|
@@ -314,7 +359,7 @@ test('records bounded interview quality and failure-point enrichment idempotentl
|
|
|
314
359
|
id: 'example-role-1', company: 'Example', role: 'Staff Product Engineer', url: 'https://jobs.example.com/123', source: 'company', score: 91,
|
|
315
360
|
status: 'submitted', submittedAt: '2026-01-15T10:00:00Z', approval: 'STANDING AUTHORIZATION', answers: {},
|
|
316
361
|
})}\n`);
|
|
317
|
-
const env =
|
|
362
|
+
const env = isolatedCliEnv(directory);
|
|
318
363
|
const base = { id: 'example-role-1', status: 'interview', occurredAt: '2026-01-20T09:00:00Z' };
|
|
319
364
|
const enriched = { ...base, interviewQuality: 'weak', failurePoint: 'role-scope' };
|
|
320
365
|
const first = JSON.parse(execFileSync(process.execPath, [script, 'ledger', 'outcome', '--stdin'], { input: JSON.stringify(base), env, encoding: 'utf8' }));
|
|
@@ -400,7 +445,7 @@ test('acknowledges a generated review only through the explicit CLI command', as
|
|
|
400
445
|
source: 'company', score: 80, status: 'submitted', submittedAt: '2026-01-01T10:00:00Z', approval: 'STANDING AUTHORIZATION', answers: {},
|
|
401
446
|
}));
|
|
402
447
|
await writeFile(join(directory, 'applications.ndjson'), `${entries.map(JSON.stringify).join('\n')}\n`);
|
|
403
|
-
const env =
|
|
448
|
+
const env = isolatedCliEnv(directory);
|
|
404
449
|
const before = JSON.parse(execFileSync(process.execPath, [script, 'ledger', 'review'], { env, encoding: 'utf8' }));
|
|
405
450
|
assert.equal(before.reviewDue, true);
|
|
406
451
|
assert.equal(await readFile(join(directory, 'reviews.ndjson'), 'utf8').catch(() => ''), '');
|
|
@@ -420,6 +465,8 @@ test('maps commands and durations to bounded telemetry categories', () => {
|
|
|
420
465
|
assert.equal(durationBucket(700), 'under-1s');
|
|
421
466
|
assert.equal(durationBucket(70_000), '1-2m');
|
|
422
467
|
assert.equal(durationBucket(2_000_000), '15m-plus');
|
|
468
|
+
assert.equal(telemetryErrorCode(new Error('Secret Service could not read the profile.')), 'authentication_required');
|
|
469
|
+
assert.equal(telemetryErrorCode(new Error('secret-tool is not installed.')), 'authentication_required');
|
|
423
470
|
});
|
|
424
471
|
|
|
425
472
|
test('builds a structured assessment event without description or candidate profile data', async () => {
|
|
@@ -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
|
-
|
|
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
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
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
|
});
|
|
@@ -53,6 +53,7 @@ test('accepts public job detail identifiers but rejects private, personal, and c
|
|
|
53
53
|
|
|
54
54
|
const rejected = [
|
|
55
55
|
'http://localhost/jobs/123',
|
|
56
|
+
'https://jobs.example.com/123',
|
|
56
57
|
'https://linkedin.com/in/some-person',
|
|
57
58
|
'https://company.example/candidate/9876543210',
|
|
58
59
|
'https://company.example/referral/12345678-1234-4123-8123-123456789abc',
|
|
@@ -61,7 +62,7 @@ test('accepts public job detail identifiers but rejects private, personal, and c
|
|
|
61
62
|
'https://company.example/jobs/access-token=abcdefghijklmnop',
|
|
62
63
|
'https://user:password@company.example/jobs/123',
|
|
63
64
|
];
|
|
64
|
-
for (const url of rejected) assert.throws(() => normalizeCommunityJob({ ...job, url }), /public HTTPS|personal|credential/i, url);
|
|
65
|
+
for (const url of rejected) assert.throws(() => normalizeCommunityJob({ ...job, url }), /public HTTPS|personal|credential|reserved example/i, url);
|
|
65
66
|
});
|
|
66
67
|
|
|
67
68
|
test('preserves only stable query job identifiers while removing referral data', async () => {
|
|
@@ -56,7 +56,7 @@ function submission(index, roundId, overrides = {}) {
|
|
|
56
56
|
id: `round-role-${index}`,
|
|
57
57
|
company: `Company ${index}`,
|
|
58
58
|
role: 'Senior Product Engineer',
|
|
59
|
-
url: `https://jobs.example
|
|
59
|
+
url: `https://jobs.fixture.example/${index}`,
|
|
60
60
|
employerJobId: `example:${index}`,
|
|
61
61
|
source: 'ashby',
|
|
62
62
|
discoverySource: 'linkedin',
|
package/package.json
CHANGED