job-application-agent 3.4.2 → 3.6.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.
Files changed (48) hide show
  1. package/README.md +23 -0
  2. package/installer/src/cli.mjs +8 -1
  3. package/installer/src/installer.mjs +8 -0
  4. package/job-application-agent/SKILL.md +32 -2
  5. package/job-application-agent/capabilities.json +3 -1
  6. package/job-application-agent/references/ACCOUNTING.md +104 -0
  7. package/job-application-agent/references/AUTONOMY.md +2 -0
  8. package/job-application-agent/references/CLOUD_STATE.md +2 -0
  9. package/job-application-agent/references/FREE_AI.md +39 -0
  10. package/job-application-agent/references/OUTREACH.md +210 -0
  11. package/job-application-agent/references/RUNS.md +35 -0
  12. package/job-application-agent/references/SCHEMAS.md +12 -2
  13. package/job-application-agent/references/agent-box/README.md +77 -0
  14. package/job-application-agent/references/agent-box/novnc.service.example +16 -0
  15. package/job-application-agent/scripts/application-accounting.mjs +246 -0
  16. package/job-application-agent/scripts/ats/answer-inject.mjs +203 -0
  17. package/job-application-agent/scripts/ats/submit-adapters.mjs +194 -0
  18. package/job-application-agent/scripts/attention-questions.mjs +111 -0
  19. package/job-application-agent/scripts/attention-resume-submit.mjs +450 -0
  20. package/job-application-agent/scripts/attention-runner-poll.mjs +328 -0
  21. package/job-application-agent/scripts/captcha-vendor.mjs +328 -0
  22. package/job-application-agent/scripts/cloud-state-client.mjs +62 -8
  23. package/job-application-agent/scripts/job-application.mjs +243 -27
  24. package/job-application-agent/scripts/novnc-display-guard.mjs +300 -0
  25. package/job-application-agent/scripts/outreach-cli.mjs +95 -0
  26. package/job-application-agent/scripts/outreach-domain.mjs +287 -0
  27. package/job-application-agent/scripts/outreach-store.mjs +72 -0
  28. package/job-application-agent/scripts/session-binding.mjs +474 -0
  29. package/job-application-agent/scripts/version.mjs +1 -1
  30. package/job-application-agent/tests/accounting-cli.test.mjs +86 -0
  31. package/job-application-agent/tests/accounting-cloud-client.test.mjs +183 -0
  32. package/job-application-agent/tests/accounting-retry-cli.test.mjs +96 -0
  33. package/job-application-agent/tests/accounting-source-race.test.mjs +109 -0
  34. package/job-application-agent/tests/answer-inject-captcha.test.mjs +169 -0
  35. package/job-application-agent/tests/application-accounting.test.mjs +315 -0
  36. package/job-application-agent/tests/attention-resume-submit.test.mjs +119 -0
  37. package/job-application-agent/tests/attention-runner-poll.test.mjs +135 -0
  38. package/job-application-agent/tests/fixtures/outreach.mjs +22 -0
  39. package/job-application-agent/tests/job-application.test.mjs +5 -0
  40. package/job-application-agent/tests/novnc-display-guard.test.mjs +50 -0
  41. package/job-application-agent/tests/outreach-cli.test.mjs +73 -0
  42. package/job-application-agent/tests/outreach.test.mjs +178 -0
  43. package/job-application-agent/tests/privacy-audit.test.mjs +2 -0
  44. package/job-application-agent/tests/review-cadence.test.mjs +66 -0
  45. package/job-application-agent/tests/session-binding.test.mjs +102 -0
  46. package/job-application-agent/tests/skill-contract.test.mjs +25 -0
  47. package/job-application-agent/tests/workflow-state.test.mjs +30 -3
  48. package/package.json +6 -3
@@ -0,0 +1,300 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Guard: live noVNC / websockify must target the fill x11vnc port (5900),
4
+ * never TigerVNC (5901). Product failure if the buyer live panel shows a
5
+ * cold XFCE / jobs listing instead of the paused filled ATS form.
6
+ *
7
+ * Usage:
8
+ * node scripts/novnc-display-guard.mjs
9
+ * node scripts/novnc-display-guard.mjs --unit /etc/systemd/system/novnc.service
10
+ * node scripts/novnc-display-guard.mjs --text "websockify ... localhost:5900"
11
+ * node scripts/novnc-display-guard.mjs --warn # warn-only (exit 0 with warning)
12
+ *
13
+ * Exit: 0 ok · 1 misconfigured / forbidden target · 2 usage
14
+ */
15
+
16
+ import { readFile } from "node:fs/promises";
17
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
+ import {
19
+ FILL_DISPLAY,
20
+ FILL_VNC_PORT,
21
+ FORBIDDEN_DISPLAY,
22
+ FORBIDDEN_VNC_PORT,
23
+ } from "./session-binding.mjs";
24
+
25
+ export const NOVNC_GUARD_EXIT = Object.freeze({
26
+ OK: 0,
27
+ FAIL: 1,
28
+ USAGE: 2,
29
+ });
30
+
31
+ /**
32
+ * Inspect free-form service / command text for websockify VNC targets.
33
+ * @param {string} text
34
+ */
35
+ export function inspectNovncTargetText(text) {
36
+ const raw = String(text ?? "");
37
+ const findings = {
38
+ fillPortMentions: [],
39
+ forbiddenPortMentions: [],
40
+ fillDisplayMentions: [],
41
+ forbiddenDisplayMentions: [],
42
+ websockifyTargets: [],
43
+ };
44
+
45
+ const portRe = /(?::|localhost\s+)\s*(5900|5901)\b|(?:^|[\s=])(5900|5901)\b/gi;
46
+ let match;
47
+ while ((match = portRe.exec(raw)) !== null) {
48
+ const port = Number(match[1] || match[2]);
49
+ if (port === FILL_VNC_PORT) findings.fillPortMentions.push(match[0].trim());
50
+ if (port === FORBIDDEN_VNC_PORT) findings.forbiddenPortMentions.push(match[0].trim());
51
+ }
52
+
53
+ if (/(?:DISPLAY=):?99\b|(?:^|[\s]) :99\b/i.test(raw)) {
54
+ findings.fillDisplayMentions.push(FILL_DISPLAY);
55
+ }
56
+ if (/(?:DISPLAY=):1\b|(?:listen|rfbport).*:1\b/i.test(raw)) {
57
+ findings.forbiddenDisplayMentions.push(FORBIDDEN_DISPLAY);
58
+ }
59
+
60
+ // Scan host:port pairs on active websockify / proxy lines (skip comment-only).
61
+ for (const line of raw.split(/\r?\n/)) {
62
+ const active = line.replace(/#.*$/, "").trim();
63
+ if (!active || !/websockify|novnc|vnc/i.test(active)) continue;
64
+ const hostPort = active.match(/(127\.0\.0\.1|localhost|\[::1\])[:\s]+(5900|5901)\b/i);
65
+ if (hostPort) {
66
+ findings.websockifyTargets.push({
67
+ host: hostPort[1],
68
+ port: Number(hostPort[2]),
69
+ line: active,
70
+ });
71
+ }
72
+ }
73
+
74
+ return findings;
75
+ }
76
+
77
+ /**
78
+ * Decide pass/fail from inspect findings + optional explicit target.
79
+ * @param {{
80
+ * text?: string,
81
+ * targetHost?: string,
82
+ * targetPort?: number,
83
+ * display?: string,
84
+ * }} input
85
+ */
86
+ export function evaluateNovncDisplayGuard(input = {}) {
87
+ const text = String(input.text ?? "");
88
+ const findings = text ? inspectNovncTargetText(text) : {
89
+ fillPortMentions: [],
90
+ forbiddenPortMentions: [],
91
+ fillDisplayMentions: [],
92
+ forbiddenDisplayMentions: [],
93
+ websockifyTargets: [],
94
+ };
95
+
96
+ const forbiddenTargets = findings.websockifyTargets.filter((t) => t.port === FORBIDDEN_VNC_PORT);
97
+ const fillTargets = findings.websockifyTargets.filter((t) => t.port === FILL_VNC_PORT);
98
+
99
+ const targetPort = input.targetPort != null
100
+ ? Number(input.targetPort)
101
+ : (forbiddenTargets[0]?.port ?? fillTargets[0]?.port ?? findings.websockifyTargets[0]?.port);
102
+ const targetHost = input.targetHost
103
+ ?? forbiddenTargets[0]?.host
104
+ ?? fillTargets[0]?.host
105
+ ?? findings.websockifyTargets[0]?.host
106
+ ?? null;
107
+ const display = input.display
108
+ ?? (findings.fillDisplayMentions[0] || findings.forbiddenDisplayMentions[0] || null);
109
+
110
+ /** @type {string[]} */
111
+ const errors = [];
112
+ /** @type {string[]} */
113
+ const warnings = [];
114
+
115
+ if (forbiddenTargets.length || (input.targetPort != null && Number(input.targetPort) === FORBIDDEN_VNC_PORT)) {
116
+ errors.push(
117
+ `Live noVNC must not target TigerVNC port ${FORBIDDEN_VNC_PORT}. Use fill x11vnc localhost:${FILL_VNC_PORT} (DISPLAY=${FILL_DISPLAY}).`,
118
+ );
119
+ }
120
+ if (findings.forbiddenDisplayMentions.length || display === FORBIDDEN_DISPLAY) {
121
+ errors.push(
122
+ `Display ${FORBIDDEN_DISPLAY} is TigerVNC — never the buyer live panel. Fill + live share DISPLAY=${FILL_DISPLAY}.`,
123
+ );
124
+ }
125
+
126
+ if (targetPort != null && Number.isFinite(targetPort)) {
127
+ if (targetPort === FORBIDDEN_VNC_PORT) {
128
+ if (!errors.some((e) => e.includes(String(FORBIDDEN_VNC_PORT)))) {
129
+ errors.push(`websockify target port ${FORBIDDEN_VNC_PORT} is forbidden.`);
130
+ }
131
+ } else if (targetPort !== FILL_VNC_PORT) {
132
+ errors.push(`websockify target port must be ${FILL_VNC_PORT} (got ${targetPort}).`);
133
+ }
134
+ } else if (text && findings.websockifyTargets.length === 0 && findings.fillPortMentions.length === 0) {
135
+ warnings.push(
136
+ `No localhost:${FILL_VNC_PORT} websockify target found in unit/text — confirm novnc.service proxies fill x11vnc.`,
137
+ );
138
+ }
139
+
140
+ // Mentions of 5901 in comments are fine when ExecStart clearly targets 5900.
141
+ if (findings.forbiddenPortMentions.length && !forbiddenTargets.length && fillTargets.length) {
142
+ warnings.push(
143
+ `Text mentions ${FORBIDDEN_VNC_PORT} but websockify target is ${FILL_VNC_PORT} — OK if the mention is a "never use" comment.`,
144
+ );
145
+ } else if (findings.forbiddenPortMentions.length && !forbiddenTargets.length && !fillTargets.length && input.targetPort == null) {
146
+ errors.push(
147
+ `Live noVNC must not target TigerVNC port ${FORBIDDEN_VNC_PORT}. Use fill x11vnc localhost:${FILL_VNC_PORT} (DISPLAY=${FILL_DISPLAY}).`,
148
+ );
149
+ }
150
+
151
+ if (targetHost && !/^(127\.0\.0\.1|localhost|\[::1\])$/i.test(targetHost)) {
152
+ warnings.push(`websockify host is ${targetHost}; prefer localhost/127.0.0.1 for fill x11vnc.`);
153
+ }
154
+
155
+ if (display && display !== FILL_DISPLAY && display !== FORBIDDEN_DISPLAY) {
156
+ warnings.push(`Unexpected display ${display}; fill contract is ${FILL_DISPLAY}.`);
157
+ }
158
+
159
+ const ok = errors.length === 0;
160
+ return {
161
+ ok,
162
+ hardRule: `live noVNC → localhost:${FILL_VNC_PORT} / DISPLAY=${FILL_DISPLAY} (never ${FORBIDDEN_VNC_PORT} / ${FORBIDDEN_DISPLAY})`,
163
+ targetHost: targetHost ?? null,
164
+ targetPort: targetPort ?? null,
165
+ display: display ?? null,
166
+ errors,
167
+ warnings,
168
+ findings,
169
+ };
170
+ }
171
+
172
+ /**
173
+ * @param {string[]} argv
174
+ */
175
+ export function parseNovncGuardArgs(argv) {
176
+ const args = { warnOnly: false, help: false, unitPath: "", text: "" };
177
+ for (let i = 0; i < argv.length; i += 1) {
178
+ const flag = argv[i];
179
+ const next = argv[i + 1];
180
+ if (flag === "--unit" || flag === "--file") {
181
+ args.unitPath = String(next ?? "").trim();
182
+ i += 1;
183
+ } else if (flag === "--text") {
184
+ args.text = String(next ?? "");
185
+ i += 1;
186
+ } else if (flag === "--target-port") {
187
+ args.targetPort = Number(next);
188
+ i += 1;
189
+ } else if (flag === "--target-host") {
190
+ args.targetHost = String(next ?? "").trim();
191
+ i += 1;
192
+ } else if (flag === "--display") {
193
+ args.display = String(next ?? "").trim();
194
+ i += 1;
195
+ } else if (flag === "--warn" || flag === "--warn-only") {
196
+ args.warnOnly = true;
197
+ } else if (flag === "--help" || flag === "-h") {
198
+ args.help = true;
199
+ } else if (flag.startsWith("-")) {
200
+ throw new Error(`Unknown flag: ${flag}`);
201
+ }
202
+ }
203
+ return args;
204
+ }
205
+
206
+ function printHelp() {
207
+ console.log(`Usage: node scripts/novnc-display-guard.mjs [options]
208
+
209
+ Options:
210
+ --unit <path> systemd unit (or any file) to scan for websockify targets
211
+ --text <string> inspect inline command text
212
+ --target-port <port> explicit VNC backend port
213
+ --target-host <host> explicit VNC backend host
214
+ --display <dpy> explicit DISPLAY (expect ${FILL_DISPLAY})
215
+ --warn warn-only: print issues but exit 0 unless --target-port is forbidden
216
+
217
+ Hard rule: websockify → localhost:${FILL_VNC_PORT} (fill DISPLAY=${FILL_DISPLAY}).
218
+ Never ${FORBIDDEN_VNC_PORT} / TigerVNC ${FORBIDDEN_DISPLAY}.
219
+ `);
220
+ }
221
+
222
+ async function main(argv = process.argv.slice(2)) {
223
+ let args;
224
+ try {
225
+ args = parseNovncGuardArgs(argv);
226
+ } catch (error) {
227
+ console.error(`[novnc-guard] ${error instanceof Error ? error.message : error}`);
228
+ process.exitCode = NOVNC_GUARD_EXIT.USAGE;
229
+ return;
230
+ }
231
+ if (args.help) {
232
+ printHelp();
233
+ return;
234
+ }
235
+
236
+ let text = args.text || "";
237
+ if (args.unitPath) {
238
+ try {
239
+ text = await readFile(args.unitPath, "utf8");
240
+ } catch (error) {
241
+ console.error(`[novnc-guard] cannot read ${args.unitPath}: ${error instanceof Error ? error.message : error}`);
242
+ process.exitCode = NOVNC_GUARD_EXIT.FAIL;
243
+ return;
244
+ }
245
+ }
246
+
247
+ if (!text && args.targetPort == null && !args.display) {
248
+ // Default: check common agent-box unit path, else print rule + usage hint.
249
+ const exampleUnit = fileURLToPath(new URL("../references/agent-box/novnc.service.example", import.meta.url));
250
+ const defaults = [
251
+ "/etc/systemd/system/novnc.service",
252
+ "/etc/systemd/system/websockify.service",
253
+ exampleUnit,
254
+ ];
255
+ for (const candidate of defaults) {
256
+ try {
257
+ text = await readFile(candidate, "utf8");
258
+ console.error(`[novnc-guard] scanning ${candidate}`);
259
+ break;
260
+ } catch {
261
+ /* try next */
262
+ }
263
+ }
264
+ }
265
+
266
+ if (!text && args.targetPort == null) {
267
+ printHelp();
268
+ console.error(`[novnc-guard] provide --unit, --text, or --target-port`);
269
+ process.exitCode = NOVNC_GUARD_EXIT.USAGE;
270
+ return;
271
+ }
272
+
273
+ const result = evaluateNovncDisplayGuard({
274
+ text,
275
+ targetPort: args.targetPort,
276
+ targetHost: args.targetHost,
277
+ display: args.display,
278
+ });
279
+
280
+ console.log(JSON.stringify(result, null, 2));
281
+
282
+ if (!result.ok) {
283
+ for (const err of result.errors) console.error(`[novnc-guard] FAIL: ${err}`);
284
+ process.exitCode = args.warnOnly ? NOVNC_GUARD_EXIT.OK : NOVNC_GUARD_EXIT.FAIL;
285
+ return;
286
+ }
287
+ for (const warn of result.warnings) console.error(`[novnc-guard] WARN: ${warn}`);
288
+ console.error(`[novnc-guard] OK — ${result.hardRule}`);
289
+ process.exitCode = NOVNC_GUARD_EXIT.OK;
290
+ }
291
+
292
+ const isDirect = import.meta.url === pathToFileURL(process.argv[1] ?? "").href
293
+ || process.argv[1]?.endsWith("novnc-display-guard.mjs");
294
+
295
+ if (isDirect) {
296
+ main().catch((error) => {
297
+ console.error(`[novnc-guard] ${error instanceof Error ? error.message : error}`);
298
+ process.exitCode = NOVNC_GUARD_EXIT.FAIL;
299
+ });
300
+ }
@@ -0,0 +1,95 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { CloudStateClient, defaultCloudConfigPath } from './cloud-state-client.mjs';
6
+ import { migrateLegacyStateDir, resolveStateDir } from './secret-store.mjs';
7
+ import { mutateOutreach, readOutreach, OUTREACH_CAPABILITY } from './outreach-domain.mjs';
8
+ import { privateOutreachWrite, withLocalOutreach, withOutreachLock } from './outreach-store.mjs';
9
+
10
+ const READS = new Set(['policy-status', 'list', 'show', 'review']);
11
+ const MUTATIONS = new Set(['policy-enable', 'policy-disable', 'assess', 'draft', 'handoff', 'record', 'suppress', 'clear']);
12
+ async function readInput() {
13
+ let text = ''; for await (const chunk of process.stdin) { text += chunk; if (text.length > 32000) throw new Error('Outreach input too large'); }
14
+ try { return JSON.parse(text); } catch { throw new Error('Invalid outreach JSON'); }
15
+ }
16
+ async function downgradeGuard() {
17
+ const agentHome = process.env.JOB_APPLICATION_AGENT_HOME || join(homedir(), '.agents');
18
+ const path = join(agentHome, 'job-application-agent', 'install.json');
19
+ let config; try { config = JSON.parse(await readFile(path, 'utf8')); } catch (e) { if (e.code === 'ENOENT') return; throw e; }
20
+ config.requiredCapabilities = [...new Set([...(config.requiredCapabilities ?? []), OUTREACH_CAPABILITY])].sort();
21
+ await privateOutreachWrite(path, JSON.stringify(config, null, 2));
22
+ }
23
+ function fromSnapshot(snapshot, action, input) {
24
+ if (action === 'policy-status') return snapshot.policy;
25
+ if (action === 'review') return snapshot.review;
26
+ if (action === 'show') { const item = snapshot.items.find(i => i.id === input.id); if (!item) throw new Error('Opportunity not found'); return item; }
27
+ return { items: snapshot.items.map(({ content, events, ...item }) => item) };
28
+ }
29
+
30
+ export async function outreachCache(directory, binding, operation, { generation, snapshot } = {}) {
31
+ return withOutreachLock(directory, 'outreach-cache.lock', async () => {
32
+ const path = join(directory, 'outreach-cloud-cache.json');
33
+ let cache;
34
+ try { cache = JSON.parse(await readFile(path, 'utf8')); } catch (error) { if (error.code !== 'ENOENT') throw error; }
35
+ if (cache?.binding !== binding) cache = { binding, generation: 0, revision: -1 };
36
+ if (operation === 'invalidate') {
37
+ cache = { binding, generation: cache.generation + 1, revision: cache.revision };
38
+ await privateOutreachWrite(path, JSON.stringify(cache));
39
+ } else if (operation === 'write' && cache.generation === generation) {
40
+ // A lower revision may be a restored backend or a delayed response. Neither
41
+ // may retain older sensitive content; invalidate all in-flight writers too.
42
+ cache = snapshot.revision < cache.revision
43
+ ? { binding, generation: generation + 1, revision: -1 }
44
+ : { binding, generation, revision: snapshot.revision, cachedAt: new Date().toISOString(), snapshot };
45
+ await privateOutreachWrite(path, JSON.stringify(cache));
46
+ }
47
+ return cache;
48
+ });
49
+ }
50
+
51
+ export async function runOutreach(args, { input: suppliedInput, stateDirectory = resolveStateDir(), cloudClient, guard = downgradeGuard, migrate = migrateLegacyStateDir } = {}) {
52
+ let [action, value, rest, extra] = args;
53
+ if (action === 'policy') { action = `policy-${value}`; value = rest; rest = extra; }
54
+ const read = READS.has(action);
55
+ if ((!read && !MUTATIONS.has(action)) || rest !== undefined || (read ? (action !== 'show' && value !== undefined) || (action === 'show' && !value) : value !== '--stdin')) {
56
+ throw new Error('Usage: outreach policy status|enable --stdin|disable --stdin; outreach assess|draft|handoff|record|suppress|clear --stdin; outreach list|show <id>|review');
57
+ }
58
+ const input = read ? (action === 'show' ? { id: value } : {}) : suppliedInput ?? await readInput();
59
+ await migrate(stateDirectory);
60
+ const cloud = cloudClient ?? new CloudStateClient({ stateDir: stateDirectory, configPath: process.env.JOB_APPLICATION_AGENT_CLOUD_CONFIG ?? defaultCloudConfigPath() });
61
+ const config = await cloud.config(true);
62
+ let result;
63
+ if (config) {
64
+ if (config.version !== 2) throw new Error('Outreach requires cloud-state-v2');
65
+ const binding = createHash('sha256').update(`${config.url}:${config.token}`).digest('hex');
66
+ const { generation } = await outreachCache(stateDirectory, binding, read ? 'read' : 'invalidate');
67
+ try {
68
+ const status = await cloud.status();
69
+ if (!status.capabilities?.includes(OUTREACH_CAPABILITY)) throw new Error('Backend upgrade required: outreach-tracking-v1 is missing');
70
+ if (!read) result = await (await cloud.request('/v2/outreach/command', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action, input }) })).json();
71
+ // Failure after a successful mutation must not disguise that committed result.
72
+ try {
73
+ const snapshot = await (await cloud.request('/v2/outreach/snapshot')).json();
74
+ await outreachCache(stateDirectory, binding, 'write', { generation, snapshot });
75
+ if (read) result = { ...fromSnapshot(snapshot, action, input), stale: false };
76
+ } catch (error) { if (read) throw error; result = { ...result, cacheRefreshed: false }; }
77
+ } catch (error) {
78
+ if (!read || !/^Cloud state unavailable:/.test(error.message)) throw error;
79
+ const cached = await outreachCache(stateDirectory, binding, 'read');
80
+ if (!cached.snapshot) throw error;
81
+ result = { ...fromSnapshot(cached.snapshot, action, input), stale: true, cachedAt: cached.cachedAt, warning: 'Offline cache may contain content cleared on another host; no mutations are allowed.' };
82
+ }
83
+ } else {
84
+ const context = { applications: [], outcomes: [] };
85
+ if ((action === 'assess' && input.applicationId) || action === 'handoff') {
86
+ for (const stream of ['applications', 'outcomes']) {
87
+ try { context[stream] = (await readFile(join(stateDirectory, `${stream}.ndjson`), 'utf8')).split('\n').filter(Boolean).map(row => JSON.parse(row)); }
88
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
89
+ }
90
+ }
91
+ result = await withLocalOutreach(stateDirectory, state => read ? { result: readOutreach(state, action, input) } : mutateOutreach(state, action, input, { ...context, actor: 'local' }));
92
+ }
93
+ if (action === 'policy-enable') await guard();
94
+ return result;
95
+ }