From d7abdd9a3e0f1e9a537799b9d3fb6f22643ec3de Mon Sep 17 00:00:00 2001 From: Kanishk Sachdev Date: Fri, 25 Sep 2026 20:18:07 -0400 Subject: [PATCH 1/4] Cover external application context and persistent panel visibility --- extension/tests/browser/review.spec.ts | 2 +- tests/test_application_motivation.py | 49 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/extension/tests/browser/review.spec.ts b/extension/tests/browser/review.spec.ts index 5f125ebd..12824f70 100644 --- a/extension/tests/browser/review.spec.ts +++ b/extension/tests/browser/review.spec.ts @@ -15,7 +15,7 @@ test("remaining fields link to controls and disappear as the person fills them", await remaining.getByRole("button", { name: "Portfolio URL Unanswered", exact: true }).click(); await expect(page.locator("#portfolio")).toBeFocused(); await page.locator("#portfolio").fill("https://example.com/portfolio"); - await page.getByRole("button", { name: "Expand panel", exact: true }).click(); + await expect(page.getByRole("button", { name: "Minimise panel", exact: true })).toBeVisible(); await expect(remaining.getByRole("button")).toHaveCount(1); await expect(remaining.getByRole("button", { name: "Resume / CV Unanswered", exact: true })).toBeVisible(); }); diff --git a/tests/test_application_motivation.py b/tests/test_application_motivation.py index d080ae11..cedc96fd 100644 --- a/tests/test_application_motivation.py +++ b/tests/test_application_motivation.py @@ -64,3 +64,52 @@ def test_missing_company_evidence_is_explicit(): rules = drafting.instructions(None) assert "Do not invent company details" in rules assert "empty string" in rules + + +def test_external_application_supplies_posting_without_creating_catalog_job( + client, user_headers, monkeypatch +): + from api import ai + from core.fetching import ats + + url = "https://jobs.ashbyhq.com/ivo-inc/b31e7195-37dd-4631-8648-422cecbb3f83/application?utm_source=Otta" + fields = [{"key": "why", "label": "Why Ivo?", "kind": "long", "options": []}] + fill = client.post( + "/v1/user/apply/resolve", + headers=user_headers, + json={"url": url, "host": "ashby", "fields": fields}, + ) + assert fill.status_code == 200, fill.text + assert fill.json()["job_id"] is None + captured = [] + fetched = [] + + def resolve(target): + fetched.append(target) + return ats.AtsResult( + ats.Status.OK, + "Software Engineer at Ivo. Build tools for legal contract review.", + "ashby", + ) + + async def parse(cfg, rules, text, schema): + captured.append(text) + return schema( + answers=[{"key": "why", "answer": "I want to build useful tools for legal teams."}] + ), {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} + + monkeypatch.setattr(ats, "resolve", resolve) + monkeypatch.setattr(ai, "parse", parse) + monkeypatch.setattr( + "api.budget.resolve_ai_config", + lambda uid, ent: type("Cfg", (), {"model": "m", "key_source": "owner"})(), + ) + response = client.post( + "/v1/user/apply/suggest", + headers=user_headers, + json={"fill_id": fill.json()["fill_id"], "fields": fields}, + ) + assert response.status_code == 200, response.text + assert "Build tools for legal contract review." in captured[0] + assert fetched == [url.split("/application")[0]] + assert db.query_one("SELECT count(*) AS n FROM jobs")["n"] == 0 From d3bec83cf0eb1bca1e6b111eb634d68e85f0a282 Mon Sep 17 00:00:00 2001 From: Kanishk Sachdev Date: Fri, 25 Sep 2026 20:21:54 -0400 Subject: [PATCH 2/4] Supply external posting evidence without requiring board membership --- docs/agents/frontend.md | 10 ++++++++ extension/package-lock.json | 4 ++-- extension/package.json | 2 +- extension/runtime/application.js | 5 +--- src/api/apply/posting_context.py | 40 ++++++++++++++++++++++++++++++++ src/api/routers/apply.py | 10 ++++---- 6 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 src/api/apply/posting_context.py diff --git a/docs/agents/frontend.md b/docs/agents/frontend.md index 1fa238fa..58fb43b8 100644 --- a/docs/agents/frontend.md +++ b/docs/agents/frontend.md @@ -159,6 +159,16 @@ success. ## Application answer updates +Extension suggestions can belong to an application fill without a catalog job. +When posting text is missing, the suggestion service uses the owning user's +stored fill URL to read supported hosted Ashby, Greenhouse or Lever posting +APIs. It does not create a catalog or board row. Arbitrary application URLs +are not scraped, and unavailable context remains explicit in the request. +Saved answer revisions and usage accounting are the same as for catalog jobs. + +Jumping from the unanswered-field checklist focuses the form control without +collapsing the extension. Panel minimization is an explicit user action. + The extension's per-field review uses `GET /user/apply/fills/{id}` and `PUT /user/apply/fills/{id}/answer`. The latter requires the field's `answer_revision`, saves `review_value` and feedback, and appends history without diff --git a/extension/package-lock.json b/extension/package-lock.json index e5479990..55097407 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "job-tracker-apply", - "version": "0.3.4", + "version": "0.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "job-tracker-apply", - "version": "0.3.4", + "version": "0.3.5", "hasInstallScript": true, "dependencies": { "react": "19.3.0", diff --git a/extension/package.json b/extension/package.json index d64c1377..721ca4b2 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,7 +1,7 @@ { "name": "job-tracker-apply", "private": true, - "version": "0.3.4", + "version": "0.3.5", "type": "module", "scripts": { "dev": "node scripts/entrypoints.mjs && wxt", diff --git a/extension/runtime/application.js b/extension/runtime/application.js index 5de4ea90..4d84b593 100644 --- a/extension/runtime/application.js +++ b/extension/runtime/application.js @@ -34,7 +34,7 @@ export async function startApplication(adapter, adapterContext, lifecycle) { const reader = adapter || { ready: () => false, submitButton: () => null, submitted: () => false }; // Stamped into every report, so a report from a build the person has not // reloaded yet is told apart from a bug (reports 9 to 11, 2026-09-08). - const BUILD = "0.3.4 remaining-field checklist"; + const BUILD = "0.3.5 external application context"; // A message to the extension's background worker. After the extension is // reloaded, a page that was already open keeps the old script, whose @@ -766,9 +766,6 @@ export async function startApplication(adapter, adapterContext, lifecycle) { if (action === "locate") { const node = field._el || field._ctl || field._box || boxOf(field); if (node) { - prefs.collapsed = true; - surface.appearance({ theme: prefs.theme, collapsed: true }); - surface.mark("jt-min", { text: "+", label: "Expand panel", expanded: false }); node.scrollIntoView?.({ block: "center", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" }); const selector = 'input:not([type="hidden"]):not([type="file"]):not(:disabled), textarea:not(:disabled), select:not(:disabled), button:not(:disabled), [contenteditable="true"]'; const target = node.matches(selector) ? node : [...node.querySelectorAll(selector)].find(el => el.getClientRects().length); diff --git a/src/api/apply/posting_context.py b/src/api/apply/posting_context.py new file mode 100644 index 00000000..2561f89b --- /dev/null +++ b/src/api/apply/posting_context.py @@ -0,0 +1,40 @@ +"""Posting evidence for applications that need not belong to the catalog.""" + +from __future__ import annotations + +import asyncio +import re +from urllib.parse import urlsplit + +from api import db +from core.fetching import ats +from core.fetching.forms import posting_urls + + +async def external_posting(user_id: int, fill_id: int) -> str | None: + fill = db.query_one( + "SELECT url FROM application_fills WHERE id = %s AND user_id = %s", + (fill_id, user_id), + ) + if not fill: + return None + # Only hosted ATS routes whose resolvers construct fixed public API + # endpoints. Never scrape arbitrary URLs supplied by an application form. + hosts = { + "jobs.ashbyhq.com", + "jobs.lever.co", + "jobs.eu.lever.co", + "boards.greenhouse.io", + "job-boards.greenhouse.io", + } + raw = urlsplit(fill["url"]) + if raw.scheme != "https" or raw.netloc not in hosts: + return None + url = ats.canonicalize(posting_urls(fill["url"])[0]) + if not url: + return None + # Tokens are path components, not encoded paths or query fragments. + if not re.fullmatch(r"/[A-Za-z0-9_-]+/(?:jobs/)?[A-Za-z0-9-]+", urlsplit(url).path): + return None + result = await asyncio.to_thread(ats.resolve, url) + return result.text if result.ok else None diff --git a/src/api/routers/apply.py b/src/api/routers/apply.py index 2508398e..5c71c406 100644 --- a/src/api/routers/apply.py +++ b/src/api/routers/apply.py @@ -23,7 +23,7 @@ ) from api.ai import access as ai_access from api.apply import drafting as drafts -from api.apply import fill_answers +from api.apply import fill_answers, posting_context from api.apply import policy as extension_policy from api.apply import recipes as extension_recipes from api.auth import AuthedUser, require_user @@ -669,9 +669,11 @@ async def suggest(body: SuggestBody, user: AuthedUser = Depends(require_user)) - parts = [] if job: parts.append(f"Job: {job['title']} at {job['company']}") - if any(f.kind in {"text", "long"} and not f.options for f in fields): - posting = get_content(job["url"]) or "" - parts.append(f"Posting:\n{posting[:6000] or '(no posting text captured)'}") + if any(f.kind in {"text", "long"} and not f.options for f in fields): + posting = get_content(job["url"]) if job else None + if not posting and body.fill_id is not None: + posting = await posting_context.external_posting(user.id, body.fill_id) + parts.append(f"Posting:\n{(posting or '')[:6000] or '(no posting text captured)'}") parts.append("Fields:\n" + json.dumps([f.model_dump() for f in fields], indent=1)) if body.review_only: parts.append( From 54a079d2cf16f93ee3f3c8267d08978de39ec031 Mon Sep 17 00:00:00 2001 From: Kanishk Sachdev Date: Fri, 25 Sep 2026 20:23:11 -0400 Subject: [PATCH 3/4] Constrain external context fetching to owned safe ATS targets --- tests/test_application_motivation.py | 46 ++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/test_application_motivation.py b/tests/test_application_motivation.py index cedc96fd..7d113cda 100644 --- a/tests/test_application_motivation.py +++ b/tests/test_application_motivation.py @@ -1,3 +1,5 @@ +import pytest + from api import db from api.apply import drafting @@ -113,3 +115,47 @@ async def parse(cfg, rules, text, schema): assert "Build tools for legal contract review." in captured[0] assert fetched == [url.split("/application")[0]] assert db.query_one("SELECT count(*) AS n FROM jobs")["n"] == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "url", + [ + "https://jobs.ashbyhq.com.evil.test/org/00000000-0000-0000-0000-000000000000", + "http://127.0.0.1/private", + "https://jobs.ashbyhq.com/org%2f..%2fprivate/00000000-0000-0000-0000-000000000000", + ], +) +async def test_external_context_never_fetches_untrusted_targets(f, monkeypatch, url): + from api.apply.posting_context import external_posting + from core.fetching import ats + + owner = f.make_user() + row = db.query_one( + "INSERT INTO application_fills (user_id, url, fields) VALUES (%s, %s, '[]') RETURNING id", + (owner, url), + ) + + def forbidden(url): + raise AssertionError("untrusted target reached the fetcher") + + monkeypatch.setattr(ats, "resolve", forbidden) + assert await external_posting(owner, row["id"]) is None + + +@pytest.mark.asyncio +async def test_external_context_cannot_read_another_users_fill(f, monkeypatch): + from api.apply.posting_context import external_posting + from core.fetching import ats + + owner, other = f.make_user(), f.make_user() + row = db.query_one( + "INSERT INTO application_fills (user_id, url, fields) VALUES (%s, %s, '[]') RETURNING id", + (owner, "https://jobs.ashbyhq.com/ivo-inc/b31e7195-37dd-4631-8648-422cecbb3f83"), + ) + + def forbidden(url): + raise AssertionError("another user's fill reached the fetcher") + + monkeypatch.setattr(ats, "resolve", forbidden) + assert await external_posting(other, row["id"]) is None From a56c12fdd8ca4c81a4f8cac23af6e2989a7f57c4 Mon Sep 17 00:00:00 2001 From: Kanishk Sachdev Date: Fri, 25 Sep 2026 20:25:30 -0400 Subject: [PATCH 4/4] Declare the application host in context security fixtures --- tests/test_application_motivation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_application_motivation.py b/tests/test_application_motivation.py index 7d113cda..4d452e2a 100644 --- a/tests/test_application_motivation.py +++ b/tests/test_application_motivation.py @@ -132,7 +132,7 @@ async def test_external_context_never_fetches_untrusted_targets(f, monkeypatch, owner = f.make_user() row = db.query_one( - "INSERT INTO application_fills (user_id, url, fields) VALUES (%s, %s, '[]') RETURNING id", + "INSERT INTO application_fills (user_id, url, host, fields) VALUES (%s, %s, 'ashby', '[]') RETURNING id", (owner, url), ) @@ -150,7 +150,7 @@ async def test_external_context_cannot_read_another_users_fill(f, monkeypatch): owner, other = f.make_user(), f.make_user() row = db.query_one( - "INSERT INTO application_fills (user_id, url, fields) VALUES (%s, %s, '[]') RETURNING id", + "INSERT INTO application_fills (user_id, url, host, fields) VALUES (%s, %s, 'ashby', '[]') RETURNING id", (owner, "https://jobs.ashbyhq.com/ivo-inc/b31e7195-37dd-4631-8648-422cecbb3f83"), )