job-application-agent 3.5.0 → 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 (38) hide show
  1. package/README.md +21 -0
  2. package/installer/src/cli.mjs +8 -1
  3. package/installer/src/installer.mjs +8 -0
  4. package/job-application-agent/SKILL.md +21 -0
  5. package/job-application-agent/capabilities.json +2 -1
  6. package/job-application-agent/references/AUTONOMY.md +2 -0
  7. package/job-application-agent/references/FREE_AI.md +39 -0
  8. package/job-application-agent/references/OUTREACH.md +210 -0
  9. package/job-application-agent/references/RUNS.md +29 -0
  10. package/job-application-agent/references/SCHEMAS.md +8 -2
  11. package/job-application-agent/references/agent-box/README.md +77 -0
  12. package/job-application-agent/references/agent-box/novnc.service.example +16 -0
  13. package/job-application-agent/scripts/ats/answer-inject.mjs +203 -0
  14. package/job-application-agent/scripts/ats/submit-adapters.mjs +194 -0
  15. package/job-application-agent/scripts/attention-questions.mjs +111 -0
  16. package/job-application-agent/scripts/attention-resume-submit.mjs +450 -0
  17. package/job-application-agent/scripts/attention-runner-poll.mjs +328 -0
  18. package/job-application-agent/scripts/captcha-vendor.mjs +328 -0
  19. package/job-application-agent/scripts/cloud-state-client.mjs +5 -1
  20. package/job-application-agent/scripts/job-application.mjs +117 -3
  21. package/job-application-agent/scripts/novnc-display-guard.mjs +300 -0
  22. package/job-application-agent/scripts/outreach-cli.mjs +95 -0
  23. package/job-application-agent/scripts/outreach-domain.mjs +287 -0
  24. package/job-application-agent/scripts/outreach-store.mjs +72 -0
  25. package/job-application-agent/scripts/session-binding.mjs +474 -0
  26. package/job-application-agent/scripts/version.mjs +1 -1
  27. package/job-application-agent/tests/answer-inject-captcha.test.mjs +169 -0
  28. package/job-application-agent/tests/attention-resume-submit.test.mjs +119 -0
  29. package/job-application-agent/tests/attention-runner-poll.test.mjs +135 -0
  30. package/job-application-agent/tests/fixtures/outreach.mjs +22 -0
  31. package/job-application-agent/tests/novnc-display-guard.test.mjs +50 -0
  32. package/job-application-agent/tests/outreach-cli.test.mjs +73 -0
  33. package/job-application-agent/tests/outreach.test.mjs +178 -0
  34. package/job-application-agent/tests/privacy-audit.test.mjs +2 -0
  35. package/job-application-agent/tests/session-binding.test.mjs +102 -0
  36. package/job-application-agent/tests/skill-contract.test.mjs +25 -0
  37. package/job-application-agent/tests/workflow-state.test.mjs +12 -1
  38. package/package.json +6 -3
@@ -14,6 +14,18 @@ import { normalizeCommunityJob, normalizeCommunitySource } from './source-commun
14
14
  import { TelemetryClient } from './telemetry-client.mjs';
15
15
  import { jobIdentity } from './telemetry-schema.mjs';
16
16
  import { CloudStateClient, defaultCloudConfigPath, enableCloudUpdateGuard, saveCloudConfig } from './cloud-state-client.mjs';
17
+ import {
18
+ SESSION_BINDING_ATTENTION_KEYS,
19
+ createSessionBinding,
20
+ extractSessionBindingFields,
21
+ sessionBindingPath,
22
+ writeSessionBindingFile,
23
+ } from './session-binding.mjs';
24
+ import {
25
+ detectAiAssistanceDiscouraged,
26
+ extractNarrativeQuestionsFromText,
27
+ normalizeAttentionQuestions,
28
+ } from './attention-questions.mjs';
17
29
 
18
30
  const SOURCES = new Set(['linkedin', 'greenhouse', 'lever', 'ashby', 'workable', 'comeet', 'workday', 'rippling', 'smartrecruiters', 'google-form', 'company', 'email', 'other']);
19
31
  const DISCOVERY_SOURCES = new Set(['direct-company', 'linkedin', 'x', 'yc', 'hacker-news', 'job-board', 'email', 'user-supplied', 'web-search', 'other']);
@@ -1302,9 +1314,62 @@ async function attentionList(roundId = null) {
1302
1314
  return { count: items.length, items };
1303
1315
  }
1304
1316
 
1317
+ async function attentionNotifyHook(event, context = {}) {
1318
+ const notifyUrl = process.env.ATTENTION_NOTIFY_URL?.trim();
1319
+ const notifySecret = process.env.ATTENTION_NOTIFY_SECRET?.trim();
1320
+ if (!notifyUrl || !notifySecret) return { attempted: false, reason: 'notify_unconfigured' };
1321
+
1322
+ let email = '';
1323
+ try { email = String(storedProfileRaw()?.email ?? '').trim().toLowerCase(); } catch { email = ''; }
1324
+ if (!email) {
1325
+ console.error('[attention-notify] profile email missing; skip notify fail-closed');
1326
+ return { attempted: true, ok: false, error: 'profile_email_missing' };
1327
+ }
1328
+
1329
+ const company = String(context.company ?? '').trim() || 'Company';
1330
+ const role = String(context.role ?? '').trim() || 'Role';
1331
+ try {
1332
+ const response = await fetch(notifyUrl, {
1333
+ method: 'POST',
1334
+ headers: {
1335
+ authorization: `Bearer ${notifySecret}`,
1336
+ 'content-type': 'application/json',
1337
+ },
1338
+ body: JSON.stringify({
1339
+ attentionId: event.id,
1340
+ email,
1341
+ company,
1342
+ role,
1343
+ url: event.url,
1344
+ stage: event.stage,
1345
+ blocker: event.blocker,
1346
+ requiredActions: event.requiredActions,
1347
+ questions: event.questions ?? [],
1348
+ aiAssistanceDiscouraged: Boolean(event.aiAssistanceDiscouraged),
1349
+ postingText: context.postingText ?? '',
1350
+ }),
1351
+ });
1352
+ const body = await response.json().catch(() => ({}));
1353
+ if (!response.ok) {
1354
+ console.error(`[attention-notify] site notify failed (${response.status}): ${body?.error ?? 'unknown'}`);
1355
+ return { attempted: true, ok: false, error: body?.error ?? 'notify_failed', status: response.status };
1356
+ }
1357
+ return { attempted: true, ok: true, magicLinkUrl: body?.magicLinkUrl ?? null, emailId: body?.emailId ?? null };
1358
+ } catch (error) {
1359
+ console.error(`[attention-notify] site notify request failed: ${error instanceof Error ? error.message : 'unknown'}`);
1360
+ return { attempted: true, ok: false, error: 'notify_request_failed' };
1361
+ }
1362
+ }
1363
+
1305
1364
  async function attentionAdd(input) {
1306
1365
  const value = object(input, 'attention item');
1307
- const allowed = new Set(['roundId', 'applicationId', 'url', 'stage', 'blocker', 'requiredActions', 'createdAt']);
1366
+ const allowed = new Set([
1367
+ 'roundId', 'applicationId', 'url', 'stage', 'blocker', 'requiredActions', 'createdAt', 'company', 'role',
1368
+ // P1.5 judgment packaging (prompts only — never candidate responses).
1369
+ 'questions', 'postingText', 'aiAssistanceDiscouraged',
1370
+ // Local-only session binding hooks — never appended to the cloud attention event.
1371
+ ...SESSION_BINDING_ATTENTION_KEYS,
1372
+ ]);
1308
1373
  for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown attention property: ${key}.`);
1309
1374
  const stage = string(value.stage, 'attention.stage', 40).toLowerCase();
1310
1375
  const blocker = string(value.blocker, 'attention.blocker', 60).toLowerCase();
@@ -1312,19 +1377,62 @@ async function attentionAdd(input) {
1312
1377
  if (!ATTENTION_BLOCKERS.has(blocker)) throw new Error('attention.blocker is invalid.');
1313
1378
  const requiredActions = stringArray(value.requiredActions, 'attention.requiredActions', true).map((item) => item.toLowerCase());
1314
1379
  if (requiredActions.length > 8 || requiredActions.some((item) => !REQUIRED_ACTIONS.has(item))) throw new Error('attention.requiredActions must contain only documented actions.');
1380
+ const applicationId = string(value.applicationId, 'attention.applicationId', 180);
1381
+ let company = typeof value.company === 'string' ? value.company.trim() : '';
1382
+ let role = typeof value.role === 'string' ? value.role.trim() : '';
1383
+ if (!company || !role) {
1384
+ try {
1385
+ const apps = await jsonLines(join(await ensureStateDir(), 'applications.ndjson'));
1386
+ const match = apps.find((entry) => entry.id === applicationId);
1387
+ if (match) {
1388
+ company = company || String(match.company ?? '').trim();
1389
+ role = role || String(match.role ?? match.title ?? '').trim();
1390
+ }
1391
+ } catch { /* best-effort context for notify only */ }
1392
+ }
1393
+ const postingText = typeof value.postingText === 'string' ? value.postingText.slice(0, 20_000) : '';
1394
+ let questions = normalizeAttentionQuestions(value.questions);
1395
+ if (!questions.length && postingText && requiredActions.includes('provide-judgment')) {
1396
+ questions = extractNarrativeQuestionsFromText(postingText);
1397
+ }
1398
+ const aiAssistanceDiscouraged = value.aiAssistanceDiscouraged === true
1399
+ || detectAiAssistanceDiscouraged(postingText);
1400
+ const localBindingFields = extractSessionBindingFields(value);
1315
1401
  const event = {
1316
1402
  type: 'opened',
1317
1403
  id: `attention-${randomUUID()}`,
1318
1404
  roundId: string(value.roundId, 'attention.roundId', 180),
1319
- applicationId: string(value.applicationId, 'attention.applicationId', 180),
1405
+ applicationId,
1320
1406
  url: string(value.url, 'attention.url', 2048),
1321
1407
  stage,
1322
1408
  blocker,
1323
1409
  requiredActions: [...new Set(requiredActions)],
1410
+ // Prompts only — never store candidate responses on the attention queue.
1411
+ ...(questions.length ? { questions } : {}),
1412
+ ...(aiAssistanceDiscouraged ? { aiAssistanceDiscouraged: true } : {}),
1324
1413
  createdAt: isoDate(value.createdAt, 'attention.createdAt'),
1325
1414
  };
1326
1415
  await appendPrivateEvent('attention', event);
1327
- return event;
1416
+
1417
+ let sessionBinding = null;
1418
+ if (localBindingFields) {
1419
+ const binding = createSessionBinding({
1420
+ attentionId: event.id,
1421
+ jobUrl: event.url,
1422
+ applicationId: event.applicationId,
1423
+ roundId: event.roundId,
1424
+ createdAt: event.createdAt,
1425
+ questions: event.questions,
1426
+ aiAssistanceDiscouraged: event.aiAssistanceDiscouraged,
1427
+ ...localBindingFields,
1428
+ });
1429
+ const path = sessionBindingPath(await ensureStateDir(), event.id);
1430
+ sessionBinding = { path, binding: await writeSessionBindingFile(path, binding) };
1431
+ }
1432
+
1433
+ const notify = await attentionNotifyHook(event, { company, role, postingText });
1434
+ const base = sessionBinding ? { ...event, sessionBinding } : event;
1435
+ return notify.attempted ? { ...base, notify } : base;
1328
1436
  }
1329
1437
 
1330
1438
  async function attentionResolve(input) {
@@ -1726,6 +1834,12 @@ async function recordInstallationStart(telemetry, session) {
1726
1834
 
1727
1835
  async function main(args) {
1728
1836
  const [area, action, value] = args;
1837
+ // Outreach is private-only, including errors: never initialize telemetry or
1838
+ // community clients, flush their queues, or run generic reconciliation here.
1839
+ if (area === 'outreach') {
1840
+ const { runOutreach } = await import('./outreach-cli.mjs');
1841
+ return print(await runOutreach(args.slice(1)));
1842
+ }
1729
1843
  const telemetry = new TelemetryClient({ stateDir: stateDir(), readIdentity: () => {
1730
1844
  const profile = storedProfileRaw();
1731
1845
  // Only explicit saved fields; no resume parsing, conversation scraping, or full profile payload.
@@ -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
+ }