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,328 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GCP / Antigravity hosted-runner poll loop for attention resume signals.
4
+ *
5
+ * Polls GET /api/internal/attention-signals/:id with ATTENTION_NOTIFY_SECRET.
6
+ * Exit codes are stable for shell loops / Antigravity task graphs:
7
+ *
8
+ * 0 resume_requested — renew lease, re-inspect ATS, submit only on visible confirm
9
+ * 10 skipped — resolve attention, no submit, continue round
10
+ * 11 aborted — release lease, end round
11
+ * 20 timeout — still waiting when --timeout elapsed
12
+ * 1 hard error — missing env, HTTP/auth failure, invalid args
13
+ *
14
+ * Env:
15
+ * ATTENTION_NOTIFY_SECRET (required) Bearer for internal poll
16
+ * PUBLIC_SITE_URL site origin, default https://jobappagent.com
17
+ * ATTENTION_NOTIFY_URL optional; origin derived if PUBLIC_SITE_URL unset
18
+ *
19
+ * Usage:
20
+ * node scripts/attention-runner-poll.mjs --attention-id attention-…
21
+ * node scripts/attention-runner-poll.mjs --attention-id attention-… --interval 5 --timeout 3600
22
+ */
23
+
24
+ import { pathToFileURL } from "node:url";
25
+
26
+ export const EXIT = Object.freeze({
27
+ RESUME: 0,
28
+ ERROR: 1,
29
+ SKIPPED: 10,
30
+ ABORTED: 11,
31
+ TIMEOUT: 20,
32
+ });
33
+
34
+ /**
35
+ * @param {string[]} argv
36
+ */
37
+ export function parseArgs(argv) {
38
+ const args = { attentionId: "", intervalSec: 5, timeoutSec: 3600, once: false };
39
+ for (let i = 0; i < argv.length; i += 1) {
40
+ const flag = argv[i];
41
+ const next = argv[i + 1];
42
+ if (flag === "--attention-id" || flag === "--id") {
43
+ args.attentionId = String(next ?? "").trim();
44
+ i += 1;
45
+ } else if (flag === "--interval") {
46
+ args.intervalSec = Number(next);
47
+ i += 1;
48
+ } else if (flag === "--timeout") {
49
+ args.timeoutSec = Number(next);
50
+ i += 1;
51
+ } else if (flag === "--once") {
52
+ args.once = true;
53
+ } else if (flag === "--help" || flag === "-h") {
54
+ args.help = true;
55
+ } else if (flag.startsWith("-")) {
56
+ throw new Error(`Unknown flag: ${flag}`);
57
+ }
58
+ }
59
+ return args;
60
+ }
61
+
62
+ /**
63
+ * @param {NodeJS.ProcessEnv} env
64
+ */
65
+ export function resolvePollConfig(env = process.env) {
66
+ const secret = String(env.ATTENTION_NOTIFY_SECRET ?? "").trim();
67
+ let siteUrl = String(env.PUBLIC_SITE_URL ?? env.ATTENTION_SITE_URL ?? "").trim();
68
+ if (!siteUrl && env.ATTENTION_NOTIFY_URL) {
69
+ try {
70
+ siteUrl = new URL(env.ATTENTION_NOTIFY_URL).origin;
71
+ } catch {
72
+ siteUrl = "";
73
+ }
74
+ }
75
+ if (!siteUrl) siteUrl = "https://jobappagent.com";
76
+ return { secret, siteUrl };
77
+ }
78
+
79
+ /**
80
+ * @param {string} siteUrl
81
+ * @param {string} attentionId
82
+ */
83
+ export function buildSignalPollUrl(siteUrl, attentionId) {
84
+ const origin = new URL(siteUrl).origin;
85
+ return new URL(`/api/internal/attention-signals/${encodeURIComponent(attentionId)}`, origin).toString();
86
+ }
87
+
88
+ /**
89
+ * @param {object} poll
90
+ */
91
+ export function interpretPoll(poll) {
92
+ if (!poll || typeof poll !== "object") {
93
+ return { done: false, waiting: true, action: "wait", message: "Empty poll body; keep waiting." };
94
+ }
95
+ if (poll.resumeRequested || poll.signal === "resume_requested") {
96
+ return {
97
+ done: true,
98
+ exitCode: EXIT.RESUME,
99
+ signal: "resume_requested",
100
+ action: "resume",
101
+ message: [
102
+ "Signal: resume_requested",
103
+ "Next: renew cloud lease → load session binding (same tab / DISPLAY=:99 / VNC 5900) →",
104
+ "re-inspect the filled ATS page → submit if possible → wait for visible confirmation →",
105
+ "ledger intent-confirm. filled ≠ applied.",
106
+ "Helper: node scripts/attention-resume-submit.mjs --attention-id … --checklist",
107
+ "If unsure or still blocked, re-open attention honestly.",
108
+ ].join(" "),
109
+ };
110
+ }
111
+ if (poll.skipped || poll.signal === "skipped") {
112
+ return {
113
+ done: true,
114
+ exitCode: EXIT.SKIPPED,
115
+ signal: "skipped",
116
+ action: "skip",
117
+ message: [
118
+ "Signal: skipped",
119
+ "Next: attention resolve (no submit) → continue the round on other roles.",
120
+ ].join(" "),
121
+ };
122
+ }
123
+ if (poll.aborted || poll.signal === "aborted") {
124
+ return {
125
+ done: true,
126
+ exitCode: EXIT.ABORTED,
127
+ signal: "aborted",
128
+ action: "abort",
129
+ message: [
130
+ "Signal: aborted",
131
+ "Next: cloud lease-release → end the round. Do not submit.",
132
+ ].join(" "),
133
+ };
134
+ }
135
+ return {
136
+ done: false,
137
+ waiting: true,
138
+ action: "wait",
139
+ message: "No candidate signal yet (pending/null). Keep polling while lease held.",
140
+ };
141
+ }
142
+
143
+ /**
144
+ * @param {{
145
+ * attentionId: string,
146
+ * siteUrl: string,
147
+ * secret: string,
148
+ * intervalSec?: number,
149
+ * timeoutSec?: number,
150
+ * once?: boolean,
151
+ * fetchImpl?: typeof fetch,
152
+ * sleep?: (ms: number) => Promise<void>,
153
+ * now?: () => number,
154
+ * log?: (line: string) => void,
155
+ * }} options
156
+ */
157
+ export async function pollAttentionSignal(options) {
158
+ const {
159
+ attentionId,
160
+ siteUrl,
161
+ secret,
162
+ intervalSec = 5,
163
+ timeoutSec = 3600,
164
+ once = false,
165
+ fetchImpl = fetch,
166
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
167
+ now = () => Date.now(),
168
+ log = console.log,
169
+ } = options;
170
+
171
+ if (!attentionId) return { ok: false, exitCode: EXIT.ERROR, error: "attention_id_required" };
172
+ if (!secret) return { ok: false, exitCode: EXIT.ERROR, error: "ATTENTION_NOTIFY_SECRET missing" };
173
+
174
+ let pollUrl;
175
+ try {
176
+ pollUrl = buildSignalPollUrl(siteUrl, attentionId);
177
+ } catch {
178
+ return { ok: false, exitCode: EXIT.ERROR, error: "site_url_invalid" };
179
+ }
180
+
181
+ const started = now();
182
+ const deadline = started + Math.max(1, timeoutSec) * 1000;
183
+ const intervalMs = Math.max(1, intervalSec) * 1000;
184
+
185
+ log(`[attention-poll] watching ${attentionId}`);
186
+ log(`[attention-poll] GET ${pollUrl} every ${intervalSec}s (timeout ${timeoutSec}s)`);
187
+
188
+ while (true) {
189
+ let response;
190
+ try {
191
+ response = await fetchImpl(pollUrl, {
192
+ method: "GET",
193
+ headers: {
194
+ authorization: `Bearer ${secret}`,
195
+ accept: "application/json",
196
+ },
197
+ });
198
+ } catch (error) {
199
+ return {
200
+ ok: false,
201
+ exitCode: EXIT.ERROR,
202
+ error: `poll_request_failed: ${error instanceof Error ? error.message : "unknown"}`,
203
+ };
204
+ }
205
+
206
+ if (response.status === 401) {
207
+ return { ok: false, exitCode: EXIT.ERROR, error: "unauthorized — check ATTENTION_NOTIFY_SECRET" };
208
+ }
209
+ if (!response.ok) {
210
+ const body = await response.text().catch(() => "");
211
+ return {
212
+ ok: false,
213
+ exitCode: EXIT.ERROR,
214
+ error: `poll_http_${response.status}${body ? `: ${body.slice(0, 200)}` : ""}`,
215
+ };
216
+ }
217
+
218
+ const poll = await response.json().catch(() => null);
219
+ const interpreted = interpretPoll(poll);
220
+ if (interpreted.done) {
221
+ log(`[attention-poll] ${interpreted.message}`);
222
+ return {
223
+ ok: true,
224
+ exitCode: interpreted.exitCode,
225
+ signal: interpreted.signal,
226
+ action: interpreted.action,
227
+ poll,
228
+ message: interpreted.message,
229
+ };
230
+ }
231
+
232
+ log(`[attention-poll] waiting… (${poll?.signal ?? "null"})`);
233
+ if (once) {
234
+ return {
235
+ ok: true,
236
+ exitCode: EXIT.TIMEOUT,
237
+ action: "wait",
238
+ poll,
239
+ message: "No signal on --once poll.",
240
+ };
241
+ }
242
+ if (now() >= deadline) {
243
+ log("[attention-poll] timeout — still no resume/skip/abort signal");
244
+ return {
245
+ ok: false,
246
+ exitCode: EXIT.TIMEOUT,
247
+ error: "timeout",
248
+ message: "Timeout waiting for candidate signal. Renew lease or re-notify.",
249
+ };
250
+ }
251
+ await sleep(intervalMs);
252
+ }
253
+ }
254
+
255
+ function printHelp() {
256
+ console.log(`Usage: node scripts/attention-runner-poll.mjs --attention-id <id> [options]
257
+
258
+ Options:
259
+ --interval <sec> Poll interval (default 5)
260
+ --timeout <sec> Max wait (default 3600)
261
+ --once Single poll then exit 20 if still waiting
262
+
263
+ Exit codes:
264
+ 0 resume_requested
265
+ 10 skipped
266
+ 11 aborted
267
+ 20 timeout / still waiting
268
+ 1 error
269
+
270
+ Env: ATTENTION_NOTIFY_SECRET, PUBLIC_SITE_URL (or ATTENTION_NOTIFY_URL origin)
271
+ `);
272
+ }
273
+
274
+ async function main(argv = process.argv.slice(2)) {
275
+ let args;
276
+ try {
277
+ args = parseArgs(argv);
278
+ } catch (error) {
279
+ console.error(`[attention-poll] ${error instanceof Error ? error.message : error}`);
280
+ process.exitCode = EXIT.ERROR;
281
+ return;
282
+ }
283
+ if (args.help) {
284
+ printHelp();
285
+ return;
286
+ }
287
+ if (!args.attentionId) {
288
+ console.error("[attention-poll] --attention-id is required");
289
+ printHelp();
290
+ process.exitCode = EXIT.ERROR;
291
+ return;
292
+ }
293
+ if (!Number.isFinite(args.intervalSec) || args.intervalSec <= 0) {
294
+ console.error("[attention-poll] --interval must be a positive number");
295
+ process.exitCode = EXIT.ERROR;
296
+ return;
297
+ }
298
+ if (!Number.isFinite(args.timeoutSec) || args.timeoutSec <= 0) {
299
+ console.error("[attention-poll] --timeout must be a positive number");
300
+ process.exitCode = EXIT.ERROR;
301
+ return;
302
+ }
303
+
304
+ const { secret, siteUrl } = resolvePollConfig(process.env);
305
+ const result = await pollAttentionSignal({
306
+ attentionId: args.attentionId,
307
+ siteUrl,
308
+ secret,
309
+ intervalSec: args.intervalSec,
310
+ timeoutSec: args.timeoutSec,
311
+ once: args.once,
312
+ });
313
+
314
+ if (!result.ok && result.error) {
315
+ console.error(`[attention-poll] ${result.error}`);
316
+ }
317
+ process.exitCode = result.exitCode;
318
+ }
319
+
320
+ const isDirect = import.meta.url === pathToFileURL(process.argv[1] ?? "").href
321
+ || process.argv[1]?.endsWith("attention-runner-poll.mjs");
322
+
323
+ if (isDirect) {
324
+ main().catch((error) => {
325
+ console.error(`[attention-poll] ${error instanceof Error ? error.message : error}`);
326
+ process.exitCode = EXIT.ERROR;
327
+ });
328
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * P2 CAPTCHA vendor scaffolding — gated Off by default.
3
+ *
4
+ * CAPTCHA_VENDOR=off|capsolver|2captcha (default off)
5
+ * Never call vendors unless vendor≠off AND api key present AND buyer opt-in.
6
+ * Fail closed to attention / live panel (complete-captcha).
7
+ *
8
+ * No cookie/session theft: solvers get only public page URL + sitekey + type.
9
+ * Solving CAPTCHA is never "applied" — submit + visible confirm still required.
10
+ */
11
+
12
+ export const CAPTCHA_VENDORS = Object.freeze(["off", "capsolver", "2captcha"]);
13
+
14
+ export const CAPTCHA_FRICTION = Object.freeze({
15
+ attempt: "captcha_vendor_attempt",
16
+ success: "captcha_vendor_success",
17
+ fail: "captcha_vendor_fail",
18
+ unsupported: "captcha_vendor_unsupported",
19
+ cap_hit: "captcha_vendor_cap_hit",
20
+ skipped_off: "captcha_vendor_skipped_off",
21
+ });
22
+
23
+ /**
24
+ * @param {NodeJS.ProcessEnv} [env]
25
+ */
26
+ export function resolveCaptchaVendorConfig(env = process.env) {
27
+ const vendorRaw = String(env.CAPTCHA_VENDOR ?? "off").trim().toLowerCase();
28
+ const vendor = CAPTCHA_VENDORS.includes(vendorRaw) ? vendorRaw : "off";
29
+ const apiKey = String(env.CAPTCHA_VENDOR_API_KEY ?? "").trim();
30
+ const spendCapUsd = Number(env.CAPTCHA_SPEND_CAP_USD_MONTH ?? 5);
31
+ const spendMonthUsd = Number(env.CAPTCHA_SPEND_MONTH_USD ?? 0);
32
+ const buyerOptIn = String(env.CAPTCHA_BUYER_OPT_IN ?? env.CAPTCHA_ASSIST ?? "")
33
+ .trim()
34
+ .toLowerCase();
35
+ const captchaAssist = buyerOptIn === "on" || buyerOptIn === "true" || buyerOptIn === "1";
36
+ return {
37
+ vendor,
38
+ apiKey,
39
+ spendCapUsd: Number.isFinite(spendCapUsd) && spendCapUsd > 0 ? spendCapUsd : 5,
40
+ spendMonthUsd: Number.isFinite(spendMonthUsd) && spendMonthUsd >= 0 ? spendMonthUsd : 0,
41
+ captchaAssist,
42
+ enabled: vendor !== "off" && Boolean(apiKey) && captchaAssist,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Hard stop when monthly spend would exceed the cap.
48
+ * @param {{ spendMonthUsd?: number, spendCapUsd?: number, additionalUsd?: number }} input
49
+ */
50
+ export function checkCaptchaSpendCap(input = {}) {
51
+ const spent = Number(input.spendMonthUsd ?? 0);
52
+ const cap = Number(input.spendCapUsd ?? 5);
53
+ const additional = Number(input.additionalUsd ?? 0);
54
+ const safeSpent = Number.isFinite(spent) ? Math.max(0, spent) : 0;
55
+ const safeCap = Number.isFinite(cap) && cap > 0 ? cap : 5;
56
+ const safeAdditional = Number.isFinite(additional) ? Math.max(0, additional) : 0;
57
+ if (safeSpent + safeAdditional > safeCap) {
58
+ return {
59
+ ok: false,
60
+ error: "spend_cap_hit",
61
+ friction: CAPTCHA_FRICTION.cap_hit,
62
+ message: "CAPTCHA assist spend cap reached. Fall back to live panel (complete-captcha).",
63
+ spendMonthUsd: safeSpent,
64
+ spendCapUsd: safeCap,
65
+ };
66
+ }
67
+ return {
68
+ ok: true,
69
+ spendMonthUsd: safeSpent,
70
+ spendCapUsd: safeCap,
71
+ remainingUsd: Math.max(0, safeCap - safeSpent),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Detect common challenge widgets from a page snapshot (no network).
77
+ * @param {{
78
+ * pageUrl?: string,
79
+ * pageHtml?: string,
80
+ * pageText?: string,
81
+ * sitekey?: string,
82
+ * challengeType?: string,
83
+ * matchedBlockerSelectors?: string[],
84
+ * }} snapshot
85
+ */
86
+ export function detectChallenge(snapshot = {}) {
87
+ const html = String(snapshot.pageHtml ?? snapshot.pageText ?? "");
88
+ const selectors = Array.isArray(snapshot.matchedBlockerSelectors)
89
+ ? snapshot.matchedBlockerSelectors.join(" ")
90
+ : "";
91
+ const haystack = `${html}\n${selectors}`;
92
+ const explicit = String(snapshot.challengeType ?? "").trim().toLowerCase();
93
+
94
+ let type = "unknown";
95
+ if (explicit) type = explicit;
96
+ else if (/cf-turnstile|turnstile/i.test(haystack)) type = "turnstile";
97
+ else if (/recaptcha\/enterprise|google\.com\/recaptcha\/enterprise/i.test(haystack)) type = "recaptcha_v2";
98
+ else if (/recaptcha|g-recaptcha|grecaptcha/i.test(haystack)) type = "recaptcha_v2";
99
+ else if (/hcaptcha/i.test(haystack)) type = "hcaptcha";
100
+ else if (/captcha/i.test(haystack)) type = "unknown";
101
+ else {
102
+ return { present: false, type: null, sitekey: null, pageurl: snapshot.pageUrl ?? null };
103
+ }
104
+
105
+ const sitekey = String(
106
+ snapshot.sitekey
107
+ ?? haystack.match(/data-sitekey=["']([^"']+)["']/i)?.[1]
108
+ ?? "",
109
+ ).trim() || null;
110
+
111
+ return {
112
+ present: true,
113
+ type,
114
+ sitekey,
115
+ pageurl: snapshot.pageUrl ?? null,
116
+ };
117
+ }
118
+
119
+ /**
120
+ * CapSolver adapter stub — refuses unless fully enabled.
121
+ * @param {object} task
122
+ * @param {ReturnType<typeof resolveCaptchaVendorConfig>} config
123
+ * @param {{ fetchImpl?: typeof fetch }} [options]
124
+ */
125
+ export async function capsolverCreateTask(task, config, options = {}) {
126
+ if (config.vendor !== "capsolver") {
127
+ return { ok: false, error: "vendor_mismatch", friction: CAPTCHA_FRICTION.skipped_off };
128
+ }
129
+ if (!config.enabled) {
130
+ return {
131
+ ok: false,
132
+ error: config.apiKey ? "buyer_opt_in_required" : "api_key_missing",
133
+ friction: CAPTCHA_FRICTION.fail,
134
+ message: "CapSolver refuse: vendor enabled only with API key + buyer captchaAssist=on.",
135
+ };
136
+ }
137
+ // Scaffold only — no real network in this slice.
138
+ void options.fetchImpl;
139
+ void task;
140
+ return {
141
+ ok: false,
142
+ error: "adapter_stub",
143
+ friction: CAPTCHA_FRICTION.unsupported,
144
+ message: "CapSolver adapter is scaffolded Off. Spike implementation gated until explicit go.",
145
+ };
146
+ }
147
+
148
+ /**
149
+ * 2Captcha adapter stub — refuses unless fully enabled.
150
+ * @param {object} task
151
+ * @param {ReturnType<typeof resolveCaptchaVendorConfig>} config
152
+ * @param {{ fetchImpl?: typeof fetch }} [options]
153
+ */
154
+ export async function twocaptchaCreateTask(task, config, options = {}) {
155
+ if (config.vendor !== "2captcha") {
156
+ return { ok: false, error: "vendor_mismatch", friction: CAPTCHA_FRICTION.skipped_off };
157
+ }
158
+ if (!config.enabled) {
159
+ return {
160
+ ok: false,
161
+ error: config.apiKey ? "buyer_opt_in_required" : "api_key_missing",
162
+ friction: CAPTCHA_FRICTION.fail,
163
+ message: "2Captcha refuse: vendor enabled only with API key + buyer captchaAssist=on.",
164
+ };
165
+ }
166
+ void options.fetchImpl;
167
+ void task;
168
+ return {
169
+ ok: false,
170
+ error: "adapter_stub",
171
+ friction: CAPTCHA_FRICTION.unsupported,
172
+ message: "2Captcha adapter is scaffolded Off. Spike implementation gated until explicit go.",
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Poll stub — always fail-closed (no network).
178
+ * @param {string} taskId
179
+ * @param {ReturnType<typeof resolveCaptchaVendorConfig>} config
180
+ */
181
+ export async function pollCaptchaTask(taskId, config) {
182
+ void taskId;
183
+ if (!config.enabled) {
184
+ return { ok: false, error: "vendor_off", friction: CAPTCHA_FRICTION.skipped_off };
185
+ }
186
+ return {
187
+ ok: false,
188
+ error: "adapter_stub",
189
+ friction: CAPTCHA_FRICTION.unsupported,
190
+ message: "CAPTCHA poll stub — no vendor calls until spike is approved.",
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Token inject guidance — never runs when vendor off.
196
+ * @param {{ token?: string, type?: string }} result
197
+ * @param {ReturnType<typeof resolveCaptchaVendorConfig>} config
198
+ */
199
+ export function injectCaptchaToken(result, config) {
200
+ if (!config.enabled) {
201
+ return {
202
+ ok: false,
203
+ error: "vendor_off",
204
+ friction: CAPTCHA_FRICTION.skipped_off,
205
+ message: "CAPTCHA vendor Off — use attention complete-captcha / live panel.",
206
+ };
207
+ }
208
+ if (!result?.token) {
209
+ return { ok: false, error: "token_missing", friction: CAPTCHA_FRICTION.fail };
210
+ }
211
+ return {
212
+ ok: false,
213
+ error: "adapter_stub",
214
+ friction: CAPTCHA_FRICTION.unsupported,
215
+ message: "Token inject stubbed. Fail closed to live panel.",
216
+ guidance: {
217
+ note: "When implemented: set textarea/input for g-recaptcha-response / cf-turnstile-response; never store token in cloud state.",
218
+ type: result.type ?? null,
219
+ },
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Single call-site helper for resume / re-inspect.
225
+ * When Off (default): identical to today — needs human complete-captcha.
226
+ *
227
+ * @param {{
228
+ * snapshot?: object,
229
+ * env?: NodeJS.ProcessEnv,
230
+ * fetchImpl?: typeof fetch,
231
+ * }} [input]
232
+ */
233
+ export async function tryCaptchaVendorAssist(input = {}) {
234
+ const config = resolveCaptchaVendorConfig(input.env ?? process.env);
235
+ const challenge = detectChallenge(input.snapshot ?? {});
236
+
237
+ if (!challenge.present) {
238
+ return { ok: true, assisted: false, reason: "no_challenge", challenge };
239
+ }
240
+
241
+ if (config.vendor === "off") {
242
+ return {
243
+ ok: false,
244
+ assisted: false,
245
+ reason: "vendor_off",
246
+ friction: CAPTCHA_FRICTION.skipped_off,
247
+ challenge,
248
+ fallback: "complete-captcha",
249
+ message: "CAPTCHA_VENDOR=off (default). Use live panel / attention complete-captcha.",
250
+ };
251
+ }
252
+
253
+ if (!config.apiKey) {
254
+ return {
255
+ ok: false,
256
+ assisted: false,
257
+ reason: "api_key_missing",
258
+ friction: CAPTCHA_FRICTION.fail,
259
+ challenge,
260
+ fallback: "complete-captcha",
261
+ message: "CAPTCHA vendor key missing — fail closed to human.",
262
+ };
263
+ }
264
+
265
+ if (!config.captchaAssist) {
266
+ return {
267
+ ok: false,
268
+ assisted: false,
269
+ reason: "buyer_opt_in_required",
270
+ friction: CAPTCHA_FRICTION.fail,
271
+ challenge,
272
+ fallback: "complete-captcha",
273
+ message: "Buyer captchaAssist is off — fail closed to human.",
274
+ };
275
+ }
276
+
277
+ const cap = checkCaptchaSpendCap({
278
+ spendMonthUsd: config.spendMonthUsd,
279
+ spendCapUsd: config.spendCapUsd,
280
+ additionalUsd: 0.01,
281
+ });
282
+ if (!cap.ok) {
283
+ return {
284
+ ok: false,
285
+ assisted: false,
286
+ reason: "spend_cap_hit",
287
+ friction: CAPTCHA_FRICTION.cap_hit,
288
+ challenge,
289
+ fallback: "complete-captcha",
290
+ message: cap.message,
291
+ };
292
+ }
293
+
294
+ const supported = new Set(["recaptcha_v2", "turnstile"]);
295
+ if (!supported.has(challenge.type)) {
296
+ return {
297
+ ok: false,
298
+ assisted: false,
299
+ reason: "unsupported_type",
300
+ friction: CAPTCHA_FRICTION.unsupported,
301
+ challenge,
302
+ fallback: "complete-captcha",
303
+ message: `Unsupported challenge type: ${challenge.type}. Fall back to live panel.`,
304
+ };
305
+ }
306
+
307
+ const task = {
308
+ type: challenge.type,
309
+ sitekey: challenge.sitekey,
310
+ pageurl: challenge.pageurl,
311
+ };
312
+
313
+ const created = config.vendor === "2captcha"
314
+ ? await twocaptchaCreateTask(task, config, { fetchImpl: input.fetchImpl })
315
+ : await capsolverCreateTask(task, config, { fetchImpl: input.fetchImpl });
316
+
317
+ return {
318
+ ok: false,
319
+ assisted: false,
320
+ reason: created.error ?? "adapter_stub",
321
+ friction: created.friction ?? CAPTCHA_FRICTION.fail,
322
+ challenge,
323
+ fallback: "complete-captcha",
324
+ message: created.message ?? "CAPTCHA vendor assist failed closed.",
325
+ // Network was not used by stubs; keep this explicit for tests.
326
+ networkCalled: false,
327
+ };
328
+ }