diff --git a/README.md b/README.md index 1322656b..1b475cfb 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,34 @@ Add your Cloudflare Workers AI account ID and API token before running this agen That means the instance runtime path is working and correctly refusing to bill the platform AI account. See [MCP Instance Runtime](docs/mcp-instance-runtime.md) for the full tool map, live test record, and OAuth troubleshooting. +### Local browser runner + +Browser-capable agents use PAGS as the control-plane brain and a local runner as the tool executor. The first local runner package lives in `packages/browser-runner`. + +```bash +pnpm --filter @proagentstore/browser-runner dev -- --port 49171 --token "$PAGS_RUNNER_TOKEN" --instance-id "$PAGS_INSTANCE_ID" +pnpm --filter @proagentstore/cli dev runner status --token "$PAGS_RUNNER_TOKEN" --instance-id "$PAGS_INSTANCE_ID" +pnpm --filter @proagentstore/cli dev runner task --type echo --input '{"ok":true}' --token "$PAGS_RUNNER_TOKEN" --instance-id "$PAGS_INSTANCE_ID" +``` + +When exposing the runner through a tunnel, start it with a token and instance binding, then register only the tunnel URL plus token with PAGS. Runtime registration is instance-scoped: PAGS stores the endpoint and encrypted runner token, then MCP/API proxy task calls to the runner with `X-PAGS-Instance-Id`. + +```bash +pnpm --filter @proagentstore/cli dev runner register "$PAGS_INSTANCE_ID" \ + --endpoint-url "$PAGS_RUNNER_ENDPOINT" \ + --runner-token "$PAGS_RUNNER_TOKEN" \ + --pags-token "$PAGS_TOKEN" \ + --probe +pnpm --filter @proagentstore/cli dev runner runtime "$PAGS_INSTANCE_ID" --pags-token "$PAGS_TOKEN" --probe +pnpm --filter @proagentstore/cli dev runner run "$PAGS_INSTANCE_ID" --type echo --input '{"ok":true}' --pags-token "$PAGS_TOKEN" +``` + +```text +subscribe_agent -> register_instance_runtime -> instance_runtime_status(probe: true) -> run_instance_task -> approve_instance_task -> instance_task_events +``` + +The browser runtime MCP tools are `register_instance_runtime`, `instance_runtime_status`, `unregister_instance_runtime`, `run_instance_task`, `approve_instance_task`, `cancel_instance_task`, and `instance_task_events`. + ### Skills and plugins ProAgentStore publishes skills through platform-specific plugin marketplaces so users can find them from both Codex and Claude Code. diff --git a/agents/job-application-assistant/.github/workflows/deploy.yml b/agents/job-application-assistant/.github/workflows/deploy.yml new file mode 100644 index 00000000..f19a6ada --- /dev/null +++ b/agents/job-application-assistant/.github/workflows/deploy.yml @@ -0,0 +1,24 @@ +name: Deploy +on: + push: + branches: [main] + workflow_dispatch: +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.30.3 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: pnpm install --no-frozen-lockfile + - run: pnpm typecheck + - run: pnpm test + - uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: deploy diff --git a/agents/job-application-assistant/.gitignore b/agents/job-application-assistant/.gitignore new file mode 100644 index 00000000..d391312c --- /dev/null +++ b/agents/job-application-assistant/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.wrangler/ diff --git a/agents/job-application-assistant/README.md b/agents/job-application-assistant/README.md new file mode 100644 index 00000000..b2ab5f85 --- /dev/null +++ b/agents/job-application-assistant/README.md @@ -0,0 +1,66 @@ +# Job Application Assistant + +A ProAgentStore agent that accepts a job URL, extracts the posting, prepares a tailored application packet, and submits only when the target exposes a simple safe form and the caller gives explicit confirmation. + +## Endpoints + +| Method | Path | Description | +|---|---|---| +| `GET` | `/` | Health check and endpoint list | +| `GET` | `/profile` | Read saved candidate profile | +| `PUT` | `/profile` | Save candidate profile fields | +| `POST` | `/applications` | Analyze a job URL and create an application packet | +| `POST` | `/run` | Alias for `/applications` for generic tool callers | +| `GET` | `/applications` | List recent application packets | +| `GET` | `/applications/:id` | Read one application packet | +| `POST` | `/applications/:id/submit` | Submit a safe basic HTML form after explicit confirmation | + +## Create an application packet + +```bash +curl -X POST https://job-application-assistant.proagentstore.online/applications \ + -H "Content-Type: application/json" \ + -d '{ + "jobUrl": "https://example.com/jobs/senior-product-engineer", + "profile": { + "fullName": "Sam Candidate", + "email": "sam@example.com", + "phone": "+1 555 0100", + "linkedin": "https://linkedin.com/in/sam", + "portfolio": "https://sam.dev", + "resumeText": "Senior full-stack engineer...", + "location": "Remote" + }, + "answers": { + "work authorization": "I am authorized to work in the United States.", + "salary": "$180k target total compensation" + } + }' +``` + +The response includes `draft.coverLetter`, `draft.shortPitch`, `draft.answers`, detected form fields, and `submission.ready`. + +## Submit with confirmation + +For simple job boards with a direct HTML form: + +```bash +curl -X POST https://job-application-assistant.proagentstore.online/applications/app_123/submit \ + -H "Content-Type: application/json" \ + -d '{"confirmation":"submit app_123"}' +``` + +Submission is blocked when the page needs login, captcha, file upload, password fields, JavaScript-only flow, or multi-step browser work. + +## Safety model + +This agent does not silently send resume/contact data. It prepares the packet first, reports blockers, and requires the exact `submit ` confirmation before any external POST/GET submission attempt. + +## Development + +```bash +pnpm install +pnpm test +pnpm typecheck +pnpm dev +``` diff --git a/agents/job-application-assistant/agent.json b/agents/job-application-assistant/agent.json new file mode 100644 index 00000000..bb872785 --- /dev/null +++ b/agents/job-application-assistant/agent.json @@ -0,0 +1,12 @@ +{ + "id": "job-application-assistant", + "name": "Job Application Assistant", + "description": "Turns a job URL into a tailored application packet, checks whether the job can be safely submitted, and submits only after explicit confirmation.", + "storeType": "agent", + "category": "productivity", + "model": "@cf/meta/llama-3.3-70b-instruct-fp8-fast", + "template": "api", + "serverConfig": { + "routes": ["job-application-assistant.proagentstore.online/*"] + } +} diff --git a/agents/job-application-assistant/package.json b/agents/job-application-assistant/package.json new file mode 100644 index 00000000..61a2c111 --- /dev/null +++ b/agents/job-application-assistant/package.json @@ -0,0 +1,21 @@ +{ + "name": "@proagentstore/job-application-assistant", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "hono": "^4.7.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250530.0", + "typescript": "^5.7.0", + "vitest": "^3.2.4", + "wrangler": "^4.0.0" + } +} diff --git a/agents/job-application-assistant/src/index.test.ts b/agents/job-application-assistant/src/index.test.ts new file mode 100644 index 00000000..bd06dc28 --- /dev/null +++ b/agents/job-application-assistant/src/index.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { + buildFallbackDraft, + buildSubmissionPlan, + buildSubmissionRequest, + extractJobPage, + mapProfileToFields, + normalizeProfile, + validateJobUrl, + validateProfile, +} from "./lib.js"; + +const profile = normalizeProfile({ + fullName: "Sam Candidate", + email: "sam@example.com", + phone: "+1 555 0100", + linkedin: "https://linkedin.com/in/sam", + portfolio: "https://sam.dev", + location: "Remote", + resumeText: + "Built distributed TypeScript systems for high-volume workflow automation. Led product engineering teams shipping customer-facing tools. Improved application conversion with structured experiments.", + workAuthorization: "Authorized to work in the United States.", + salaryExpectations: "$180k target total compensation.", +}); + +describe("job URL and profile validation", () => { + it("accepts http and https job URLs only", () => { + expect(validateJobUrl("https://example.com/jobs/1")).toBe("https://example.com/jobs/1"); + expect(() => validateJobUrl("ftp://example.com/jobs/1")).toThrow("http or https"); + expect(() => validateJobUrl("not a url")).toThrow("valid URL"); + }); + + it("requires candidate name and valid email", () => { + expect(validateProfile(profile)).toEqual([]); + expect(validateProfile(normalizeProfile({ email: "bad" }))).toEqual([ + "profile.fullName is required", + "profile.email must be a valid email address", + ]); + }); +}); + +describe("job page extraction", () => { + it("extracts title, company, apply link, form fields, and text", () => { + const job = extractJobPage( + ` + Senior Product Engineer + +

Senior Product Engineer

+

Build customer-facing workflow systems.

+ Apply now +
+ + + +
`, + "https://jobs.example.com/roles/123", + ); + + expect(job.title).toBe("Senior Product Engineer"); + expect(job.company).toBe("Acme"); + expect(job.applyUrl).toBe("https://jobs.example.com/apply"); + expect(job.forms[0].action).toBe("https://jobs.example.com/apply"); + expect(job.forms[0].fields.map((field) => field.name)).toEqual([ + "full_name", + "email", + "cover_letter", + ]); + expect(job.descriptionText).toContain("Build customer-facing workflow systems"); + }); + + it("flags blockers for captcha, login, file upload, and password fields", () => { + const job = extractJobPage( + `

Role

+

Please sign in and complete recaptcha.

+
`, + "https://example.com/job", + ); + + expect(job.blockers).toContain("Captcha detected."); + expect(job.blockers).toContain("Login or account creation may be required."); + expect(job.blockers).toContain("File upload fields require manual review."); + expect(job.blockers).toContain("Password fields require manual review."); + }); +}); + +describe("application preparation and submission planning", () => { + it("maps candidate fields into a safe application form", () => { + const job = extractJobPage( + `

Senior Product Engineer

+
+ + + + + +
`, + "https://jobs.example.com/roles/123", + ); + const draft = buildFallbackDraft(job, profile); + + expect(mapProfileToFields(job.forms[0].fields, profile, draft, {})).toMatchObject({ + first_name: "Sam", + last_name: "Candidate", + email: "sam@example.com", + linkedin: "https://linkedin.com/in/sam", + why_this_role: draft.coverLetter, + }); + }); + + it("requires exact confirmation before building a submission request", () => { + const job = extractJobPage( + `

Senior Product Engineer

+
+ + +
`, + "https://jobs.example.com/roles/123", + ); + const draft = buildFallbackDraft(job, profile); + const plan = buildSubmissionPlan("app_123", job, profile, draft, {}); + + expect(plan.ready).toBe(true); + expect(plan.confirmationPhrase).toBe("submit app_123"); + expect(() => buildSubmissionRequest(job.url, plan, "yes")).toThrow("confirmation"); + const request = buildSubmissionRequest(job.url, plan, "submit app_123"); + expect(request.url).toBe("https://jobs.example.com/apply"); + expect(request.init.method).toBe("POST"); + expect(request.fields).toMatchObject({ + full_name: "Sam Candidate", + email: "sam@example.com", + }); + }); + + it("blocks automatic submission when only unsafe forms are present", () => { + const job = extractJobPage( + `

Senior Product Engineer

`, + "https://jobs.example.com/roles/123", + ); + const draft = buildFallbackDraft(job, profile); + const plan = buildSubmissionPlan("app_123", job, profile, draft, {}); + + expect(plan.ready).toBe(false); + expect(plan.blockers).toContain("File upload fields require manual review."); + expect(() => buildSubmissionRequest(job.url, plan, "submit app_123")).toThrow( + "File upload", + ); + }); +}); diff --git a/agents/job-application-assistant/src/index.ts b/agents/job-application-assistant/src/index.ts new file mode 100644 index 00000000..40cb21ed --- /dev/null +++ b/agents/job-application-assistant/src/index.ts @@ -0,0 +1,301 @@ +import { Hono } from "hono"; +import type { Context } from "hono"; +import { + type ApplicationDraft, + type CandidateProfile, + type JobPage, + buildApplicationPrompt, + buildFallbackDraft, + buildSubmissionPlan, + buildSubmissionRequest, + extractJobPage, + normalizeProfile, + parseAiDraft, + validateJobUrl, + validateProfile, +} from "./lib.js"; + +interface Env { + AI: Ai; + APPLICATIONS: DurableObjectNamespace; + API_SECRET?: string; +} + +interface ApplicationCreateRequest { + jobUrl: string; + profile?: Partial; + answers?: Record; +} + +interface ApplicationRecord { + id: string; + jobUrl: string; + status: "ready_for_review" | "blocked" | "submitted"; + profile: CandidateProfile; + job: JobPage; + draft: ApplicationDraft; + answers: Record; + submission: ReturnType; + createdAt: string; + updatedAt: string; + submittedAt?: string; + submissionResponse?: { + status: number; + ok: boolean; + url: string; + }; +} + +const MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast" as Parameters[0]; +const app = new Hono<{ Bindings: Env }>(); + +app.use("*", async (c, next) => { + const secret = c.env.API_SECRET; + if (!secret) return next(); + const auth = c.req.header("authorization") || ""; + if (auth !== `Bearer ${secret}`) return c.json({ error: "Unauthorized" }, 401); + return next(); +}); + +app.get("/", (c) => + c.json({ + agent: "job-application-assistant", + type: "agent", + status: "ok", + model: MODEL, + safety: "Prepares application packets first; external submission requires explicit confirmation.", + endpoints: [ + "GET /profile", + "PUT /profile", + "POST /applications", + "POST /run", + "GET /applications", + "GET /applications/:id", + "POST /applications/:id/submit", + ], + }), +); + +app.get("/profile", async (c) => proxyStore(c.env, "/profile")); + +app.put("/profile", async (c) => { + const body = await c.req.json>().catch(() => null); + if (!body) return c.json({ error: "Invalid JSON body" }, 400); + const profile = normalizeProfile(body); + const errors = validateProfile(profile); + if (errors.length) return c.json({ error: "Invalid profile", details: errors }, 400); + return proxyStore(c.env, "/profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(profile), + }); +}); + +app.post("/run", async (c) => createApplication(c)); +app.post("/applications", async (c) => createApplication(c)); + +app.get("/applications", async (c) => proxyStore(c.env, "/applications")); +app.get("/applications/:id", async (c) => + proxyStore(c.env, `/applications/${encodeURIComponent(c.req.param("id"))}`), +); + +app.post("/applications/:id/submit", async (c) => { + const id = c.req.param("id"); + const body = (await c.req.json<{ confirmation?: string }>().catch(() => ({}))) as { + confirmation?: string; + }; + const recordRes = await store(c.env).fetch(new Request(`http://store/applications/${id}`)); + if (!recordRes.ok) return new Response(recordRes.body, { status: recordRes.status, headers: recordRes.headers }); + const record = await recordRes.json(); + + let submission: ReturnType; + try { + submission = buildSubmissionRequest( + record.job.url, + record.submission, + body.confirmation || "", + ); + } catch (error) { + return c.json( + { + error: "submission_not_ready", + message: error instanceof Error ? error.message : "Application is not ready.", + submission: record.submission, + }, + 409, + ); + } + + const res = await fetch(submission.url, submission.init); + const updated: ApplicationRecord = { + ...record, + status: res.ok ? "submitted" : "blocked", + updatedAt: new Date().toISOString(), + submittedAt: res.ok ? new Date().toISOString() : record.submittedAt, + submissionResponse: { + status: res.status, + ok: res.ok, + url: res.url || submission.url, + }, + }; + await store(c.env).fetch(new Request(`http://store/applications/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updated), + })); + + return c.json({ + id, + status: updated.status, + response: updated.submissionResponse, + fieldsSubmitted: Object.keys(submission.fields), + }); +}); + +async function createApplication(c: Context<{ Bindings: Env }>) { + const body = await c.req.json().catch(() => null); + if (!body) return c.json({ error: "Invalid JSON body" }, 400); + + let jobUrl: string; + try { + jobUrl = validateJobUrl(body.jobUrl); + } catch (error) { + return c.json({ error: error instanceof Error ? error.message : "Invalid jobUrl" }, 400); + } + + const savedProfileRes = await store(c.env).fetch(new Request("http://store/profile")); + const savedProfile = savedProfileRes.ok + ? await savedProfileRes.json>() + : {}; + const profile = normalizeProfile({ ...savedProfile, ...body.profile }); + const profileErrors = validateProfile(profile); + if (profileErrors.length) { + return c.json({ error: "Invalid profile", details: profileErrors }, 400); + } + + const htmlRes = await fetch(jobUrl, { + headers: { + "User-Agent": "ProAgentStore Job Application Assistant/0.1", + Accept: "text/html,application/xhtml+xml", + }, + }); + if (!htmlRes.ok) { + return c.json({ error: "Failed to fetch job URL", status: htmlRes.status }, 502); + } + const html = await htmlRes.text(); + const job = extractJobPage(html, jobUrl); + const answers = Object.fromEntries( + Object.entries(body.answers || {}).map(([key, value]) => [key, String(value).slice(0, 2_000)]), + ); + const fallback = buildFallbackDraft(job, profile, answers); + const draft = await generateDraft(c.env, job, profile, answers, fallback); + const id = `app_${crypto.randomUUID()}`; + const submission = buildSubmissionPlan(id, job, profile, draft, answers); + const now = new Date().toISOString(); + const record: ApplicationRecord = { + id, + jobUrl, + status: submission.ready ? "ready_for_review" : "blocked", + profile, + job, + draft, + answers, + submission, + createdAt: now, + updatedAt: now, + }; + + const saveRes = await store(c.env).fetch(new Request("http://store/applications", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(record), + })); + if (!saveRes.ok) return c.json({ error: "Failed to save application" }, 500); + return c.json(record, 201); +} + +async function generateDraft( + env: Env, + job: JobPage, + profile: CandidateProfile, + answers: Record, + fallback: ApplicationDraft, +): Promise { + try { + const result = await env.AI.run(MODEL, { + messages: [ + { + role: "system", + content: + "You are a careful job application assistant. You prepare truthful application material and never invent facts.", + }, + { role: "user", content: buildApplicationPrompt(job, profile, answers) }, + ], + }); + return parseAiDraft(result, fallback); + } catch { + return fallback; + } +} + +function store(env: Env): DurableObjectStub { + return env.APPLICATIONS.get(env.APPLICATIONS.idFromName("store")); +} + +function proxyStore(env: Env, path: string, init?: RequestInit): Promise { + return store(env).fetch(new Request(`http://store${path}`, init)); +} + +export class ApplicationStoreDO { + constructor(private state: DurableObjectState) {} + + async fetch(request: Request): Promise { + const url = new URL(request.url); + const path = url.pathname; + + if (path === "/profile") { + if (request.method === "GET") { + return Response.json((await this.state.storage.get("profile")) || {}); + } + if (request.method === "PUT") { + const profile = await request.json(); + await this.state.storage.put("profile", profile); + return Response.json(profile); + } + } + + if (path === "/applications" && request.method === "GET") { + const rows = await this.state.storage.list({ + prefix: "application:", + reverse: true, + limit: 50, + }); + return Response.json({ applications: [...rows.values()] }); + } + + if (path === "/applications" && request.method === "POST") { + const record = await request.json(); + await this.state.storage.put(`application:${record.id}`, record); + return Response.json({ id: record.id }, { status: 201 }); + } + + const match = path.match(/^\/applications\/([^/]+)$/); + if (match) { + const id = decodeURIComponent(match[1]); + const key = `application:${id}`; + if (request.method === "GET") { + const record = await this.state.storage.get(key); + return record ? Response.json(record) : Response.json({ error: "Not found" }, { status: 404 }); + } + if (request.method === "PUT") { + const record = await request.json(); + await this.state.storage.put(key, record); + return Response.json({ id }); + } + } + + return new Response("Not found", { status: 404 }); + } +} + +export default app; diff --git a/agents/job-application-assistant/src/lib.ts b/agents/job-application-assistant/src/lib.ts new file mode 100644 index 00000000..1c84ecd7 --- /dev/null +++ b/agents/job-application-assistant/src/lib.ts @@ -0,0 +1,484 @@ +export interface CandidateProfile { + fullName: string; + email: string; + phone?: string; + location?: string; + linkedin?: string; + portfolio?: string; + resumeText?: string; + coverLetterStyle?: string; + workAuthorization?: string; + salaryExpectations?: string; + noticePeriod?: string; + extra?: Record; +} + +export interface FormField { + name: string; + type: string; + label: string; + required: boolean; +} + +export interface JobForm { + action: string; + method: "get" | "post"; + fields: FormField[]; + score: number; +} + +export interface JobPage { + url: string; + title: string; + company: string; + descriptionText: string; + applyUrl: string; + forms: JobForm[]; + blockers: string[]; +} + +export interface ApplicationDraft { + coverLetter: string; + shortPitch: string; + answers: Record; + resumeHighlights: string[]; +} + +export interface SubmissionPlan { + ready: boolean; + requiresConfirmation: true; + confirmationPhrase: string; + form?: JobForm; + blockers: string[]; + mappedFields: Record; +} + +export interface SubmissionRequest { + url: string; + init: RequestInit; + fields: Record; +} + +const BLOCKED_FIELD_TYPES = new Set(["file", "password"]); +const MAX_TEXT = 12_000; + +export function normalizeProfile(input: Partial | undefined): CandidateProfile { + const profile = input || {}; + return { + fullName: clean(profile.fullName), + email: clean(profile.email), + phone: optional(profile.phone), + location: optional(profile.location), + linkedin: optional(profile.linkedin), + portfolio: optional(profile.portfolio), + resumeText: optional(profile.resumeText, 8_000), + coverLetterStyle: optional(profile.coverLetterStyle, 800), + workAuthorization: optional(profile.workAuthorization, 500), + salaryExpectations: optional(profile.salaryExpectations, 300), + noticePeriod: optional(profile.noticePeriod, 300), + extra: cleanRecord(profile.extra), + }; +} + +export function validateProfile(profile: CandidateProfile): string[] { + const errors: string[] = []; + if (!profile.fullName) errors.push("profile.fullName is required"); + if (!profile.email) errors.push("profile.email is required"); + if (profile.email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(profile.email)) { + errors.push("profile.email must be a valid email address"); + } + return errors; +} + +export function validateJobUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("jobUrl must be a valid URL"); + } + if (!["http:", "https:"].includes(url.protocol)) { + throw new Error("jobUrl must use http or https"); + } + return url.toString(); +} + +export function extractJobPage(html: string, jobUrl: string): JobPage { + const base = validateJobUrl(jobUrl); + const text = stripHtml(html).slice(0, MAX_TEXT); + const title = + meta(html, "og:title") || + tagText(html, "h1") || + tagText(html, "title") || + "Untitled job"; + const company = + meta(html, "og:site_name") || + valueNearLabel(text, ["company", "employer", "organization"]) || + "Unknown company"; + const applyUrl = findApplyUrl(html, base); + const forms = extractForms(html, base); + const blockers = detectPageBlockers(html, forms); + + return { + url: base, + title: normalizeWhitespace(title).slice(0, 160), + company: normalizeWhitespace(company).slice(0, 120), + descriptionText: text, + applyUrl, + forms, + blockers, + }; +} + +export function buildFallbackDraft( + job: JobPage, + profile: CandidateProfile, + answers: Record = {}, +): ApplicationDraft { + const highlights = resumeHighlights(profile.resumeText || ""); + const name = profile.fullName || "Candidate"; + const role = job.title || "the role"; + const company = job.company || "your team"; + const style = profile.coverLetterStyle ? ` ${profile.coverLetterStyle}` : ""; + return { + coverLetter: [ + `Dear ${company} hiring team,`, + "", + `I am applying for ${role}. My background is a strong match for the work described in the posting, and I am interested in contributing to ${company}.${style}`, + "", + highlights.length + ? `Relevant highlights include ${joinSentence(highlights)}.` + : "I can bring focused execution, clear communication, and practical problem solving to this role.", + "", + `Thank you for considering my application.`, + "", + name, + ].join("\n"), + shortPitch: `${name} is a strong candidate for ${role}, with relevant experience and a profile tailored to ${company}.`, + answers: { + "Why are you interested in this role?": `The ${role} opportunity at ${company} matches my experience and the kind of impact I want to make.`, + ...answers, + }, + resumeHighlights: highlights, + }; +} + +export function buildApplicationPrompt( + job: JobPage, + profile: CandidateProfile, + answers: Record, +): string { + return JSON.stringify({ + task: "Create a truthful job application packet.", + output: { + coverLetter: "string", + shortPitch: "string", + answers: "object of likely application question answers", + resumeHighlights: "array of 3-5 concise strings", + }, + rules: [ + "Do not invent credentials, employment history, education, certifications, or work authorization.", + "Use only the candidate profile and job posting text.", + "Keep the cover letter under 350 words.", + "Return only JSON.", + ], + job: { + url: job.url, + title: job.title, + company: job.company, + descriptionText: job.descriptionText.slice(0, 7_000), + }, + candidate: profile, + callerAnswers: answers, + }); +} + +export function parseAiDraft(value: unknown, fallback: ApplicationDraft): ApplicationDraft { + const raw = + typeof value === "object" && value && "response" in value + ? (value as { response?: unknown }).response + : value; + const text = typeof raw === "string" ? raw : JSON.stringify(raw || {}); + const jsonText = text.match(/\{[\s\S]*\}/)?.[0] || text; + try { + const parsed = JSON.parse(jsonText) as Partial; + return { + coverLetter: clean(parsed.coverLetter, 5_000) || fallback.coverLetter, + shortPitch: clean(parsed.shortPitch, 1_000) || fallback.shortPitch, + answers: { ...fallback.answers, ...cleanRecord(parsed.answers) }, + resumeHighlights: Array.isArray(parsed.resumeHighlights) + ? parsed.resumeHighlights.map((item) => clean(item, 300)).filter(Boolean).slice(0, 5) + : fallback.resumeHighlights, + }; + } catch { + return fallback; + } +} + +export function buildSubmissionPlan( + applicationId: string, + job: JobPage, + profile: CandidateProfile, + draft: ApplicationDraft, + answers: Record, +): SubmissionPlan { + const form = selectApplicationForm(job.forms); + const blockers = [...job.blockers]; + if (!form) blockers.push("No safe application form was detected on the job page."); + const mappedFields = form ? mapProfileToFields(form.fields, profile, draft, answers) : {}; + if (form && Object.keys(mappedFields).length < Math.min(2, form.fields.length)) { + blockers.push("Detected form fields could not be mapped confidently from the candidate profile."); + } + return { + ready: Boolean(form) && blockers.length === 0, + requiresConfirmation: true, + confirmationPhrase: `submit ${applicationId}`, + form, + blockers: unique(blockers), + mappedFields, + }; +} + +export function buildSubmissionRequest( + pageUrl: string, + plan: SubmissionPlan, + confirmation: string, +): SubmissionRequest { + if (confirmation !== plan.confirmationPhrase) { + throw new Error(`confirmation must exactly equal "${plan.confirmationPhrase}"`); + } + if (!plan.ready || !plan.form) { + throw new Error(plan.blockers[0] || "application is not ready for automatic submission"); + } + const targetUrl = absoluteUrl(plan.form.action || pageUrl, pageUrl); + const body = new URLSearchParams(plan.mappedFields); + if (plan.form.method === "get") { + const url = new URL(targetUrl); + for (const [key, value] of body.entries()) url.searchParams.set(key, value); + return { url: url.toString(), init: { method: "GET" }, fields: plan.mappedFields }; + } + return { + url: targetUrl, + init: { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }, + fields: plan.mappedFields, + }; +} + +export function mapProfileToFields( + fields: FormField[], + profile: CandidateProfile, + draft: ApplicationDraft, + answers: Record, +): Record { + const [firstName, ...rest] = profile.fullName.split(/\s+/).filter(Boolean); + const lastName = rest.join(" "); + const mapped: Record = {}; + for (const field of fields) { + if (!field.name || field.type === "hidden" || BLOCKED_FIELD_TYPES.has(field.type)) continue; + const key = `${field.name} ${field.label}`.toLowerCase(); + const answer = answerForField(key, answers); + const value = + answer || + (key.includes("first") && firstName) || + (key.includes("last") && lastName) || + (nameLike(key) && profile.fullName) || + (key.includes("email") && profile.email) || + ((key.includes("phone") || key.includes("mobile")) && profile.phone) || + ((key.includes("linkedin") || key.includes("linked in")) && profile.linkedin) || + ((key.includes("portfolio") || key.includes("website")) && profile.portfolio) || + (key.includes("location") && profile.location) || + ((key.includes("authorization") || key.includes("eligible")) && profile.workAuthorization) || + (key.includes("salary") && profile.salaryExpectations) || + (key.includes("notice") && profile.noticePeriod) || + ((key.includes("cover") || key.includes("message") || key.includes("why")) && draft.coverLetter) || + ((key.includes("resume") || key.includes("cv")) && (profile.resumeText || draft.shortPitch)) || + (profile.extra ? answerForField(key, profile.extra) : ""); + if (value) mapped[field.name] = value; + } + return mapped; +} + +export function extractForms(html: string, pageUrl: string): JobForm[] { + const forms: JobForm[] = []; + for (const match of html.matchAll(/]*)>([\s\S]*?)<\/form>/gi)) { + const attrs = match[1] || ""; + const inner = match[2] || ""; + const method = attr(attrs, "method").toLowerCase() === "get" ? "get" : "post"; + const action = attr(attrs, "action") || pageUrl; + const fields = extractFields(inner); + const searchable = `${attrs} ${inner}`.toLowerCase(); + const score = + (searchable.includes("apply") ? 4 : 0) + + (searchable.includes("resume") || searchable.includes("cv") ? 3 : 0) + + (searchable.includes("email") ? 1 : 0) + + (searchable.includes("cover") ? 1 : 0) + + fields.length; + forms.push({ action: absoluteUrl(action, pageUrl), method, fields, score }); + } + return forms.sort((a, b) => b.score - a.score); +} + +export function selectApplicationForm(forms: JobForm[]): JobForm | undefined { + return forms.find((form) => detectFormBlockers(form).length === 0); +} + +export function detectPageBlockers(html: string, forms: JobForm[]): string[] { + const lower = html.toLowerCase(); + const blockers: string[] = []; + if (lower.includes("recaptcha") || lower.includes("g-recaptcha") || lower.includes("hcaptcha")) { + blockers.push("Captcha detected."); + } + if (lower.includes("sign in") || lower.includes("log in") || lower.includes("create account")) { + blockers.push("Login or account creation may be required."); + } + for (const form of forms) blockers.push(...detectFormBlockers(form)); + return unique(blockers); +} + +export function detectFormBlockers(form: JobForm): string[] { + const blockers: string[] = []; + if (form.fields.some((field) => field.type === "file")) { + blockers.push("File upload fields require manual review."); + } + if (form.fields.some((field) => field.type === "password")) { + blockers.push("Password fields require manual review."); + } + if (form.fields.some((field) => /captcha|recaptcha|hcaptcha/i.test(`${field.name} ${field.label}`))) { + blockers.push("Captcha fields require manual review."); + } + if (form.fields.length === 0) blockers.push("Form has no named fields."); + return unique(blockers); +} + +export function stripHtml(html: string): string { + return normalizeWhitespace( + html + .replace(//gi, " ") + .replace(//gi, " ") + .replace(//gi, "\n") + .replace(/<\/(p|div|section|li|h[1-6])>/gi, "\n") + .replace(/<[^>]+>/g, " ") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/'/g, "'") + .replace(/"/g, '"'), + ); +} + +function extractFields(html: string): FormField[] { + const fields: FormField[] = []; + for (const match of html.matchAll(/<(input|textarea|select)\b([^>]*)>/gi)) { + const tag = match[1].toLowerCase(); + const attrs = match[2] || ""; + const name = attr(attrs, "name"); + if (!name) continue; + const type = tag === "input" ? attr(attrs, "type").toLowerCase() || "text" : tag; + fields.push({ + name, + type, + label: attr(attrs, "aria-label") || attr(attrs, "placeholder") || name, + required: /\brequired\b/i.test(attrs), + }); + } + return fields; +} + +function findApplyUrl(html: string, baseUrl: string): string { + for (const match of html.matchAll(/]*)>([\s\S]*?)<\/a>/gi)) { + const label = stripHtml(match[2] || "").toLowerCase(); + const href = attr(match[1] || "", "href"); + if (href && /apply|application|submit/.test(label + " " + href.toLowerCase())) { + return absoluteUrl(href, baseUrl); + } + } + return baseUrl; +} + +function meta(html: string, key: string): string { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`]*(?:property|name)=["']${escaped}["'][^>]*)>`, "i"); + const match = html.match(re); + return match ? attr(match[1], "content") : ""; +} + +function tagText(html: string, tag: string): string { + const match = html.match(new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i")); + return match ? stripHtml(match[1]) : ""; +} + +function valueNearLabel(text: string, labels: string[]): string { + for (const label of labels) { + const re = new RegExp(`${label}\\s*:?\\s*([^\\n|•]{2,80})`, "i"); + const match = text.match(re); + if (match) return match[1].trim(); + } + return ""; +} + +function attr(attrs: string, name: string): string { + const re = new RegExp(`${name}\\s*=\\s*("[^"]*"|'[^']*'|[^\\s>]+)`, "i"); + const match = attrs.match(re); + if (!match) return ""; + return match[1].replace(/^['"]|['"]$/g, "").trim(); +} + +function answerForField(key: string, answers: Record): string { + for (const [answerKey, answerValue] of Object.entries(answers)) { + if (key.includes(answerKey.toLowerCase())) return answerValue; + } + return ""; +} + +function nameLike(key: string): boolean { + const normalized = key.replace(/[_-]+/g, " "); + return (normalized.includes("full name") || /\bname\b/.test(normalized)) && !normalized.includes("company"); +} + +function resumeHighlights(resumeText: string): string[] { + return resumeText + .split(/[\n.;]+/) + .map((line) => clean(line, 180)) + .filter((line) => line.length > 35) + .slice(0, 4); +} + +function joinSentence(parts: string[]): string { + if (parts.length <= 1) return parts[0] || ""; + return `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}`; +} + +function absoluteUrl(value: string, base: string): string { + return new URL(value || base, base).toString(); +} + +function normalizeWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function clean(value: unknown, max = 1_000): string { + return String(value || "").replace(/\0/g, "").trim().slice(0, max); +} + +function optional(value: unknown, max = 1_000): string | undefined { + const cleaned = clean(value, max); + return cleaned || undefined; +} + +function cleanRecord(value: unknown): Record { + if (!value || typeof value !== "object") return {}; + return Object.fromEntries( + Object.entries(value as Record) + .map(([key, item]) => [clean(key, 100), clean(item, 2_000)]) + .filter(([key, item]) => key && item), + ); +} + +function unique(values: string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} diff --git a/agents/job-application-assistant/tsconfig.json b/agents/job-application-assistant/tsconfig.json new file mode 100644 index 00000000..e283c2ab --- /dev/null +++ b/agents/job-application-assistant/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/agents/job-application-assistant/vitest.config.ts b/agents/job-application-assistant/vitest.config.ts new file mode 100644 index 00000000..2f671880 --- /dev/null +++ b/agents/job-application-assistant/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/agents/job-application-assistant/wrangler.toml b/agents/job-application-assistant/wrangler.toml new file mode 100644 index 00000000..1301c073 --- /dev/null +++ b/agents/job-application-assistant/wrangler.toml @@ -0,0 +1,22 @@ +name = "proagentstore-job-application-assistant" +main = "src/index.ts" +compatibility_date = "2026-01-01" +compatibility_flags = ["nodejs_compat"] + +[[routes]] +pattern = "job-application-assistant.proagentstore.online/*" +zone_name = "proagentstore.online" + +[ai] +binding = "AI" + +[[durable_objects.bindings]] +name = "APPLICATIONS" +class_name = "ApplicationStoreDO" + +[[migrations]] +tag = "v1" +new_classes = ["ApplicationStoreDO"] + +# Secrets: +# API_SECRET — optional bearer token for private direct API use. diff --git a/docs/browser-capable-agent-runtime.md b/docs/browser-capable-agent-runtime.md new file mode 100644 index 00000000..54a8f1b9 --- /dev/null +++ b/docs/browser-capable-agent-runtime.md @@ -0,0 +1,621 @@ +# Browser-Capable Agent Runtime + +ProAgentStore needs a runtime model for agents that can drive a real browser. Job application agents are the reference case: they need saved logins, interactive pages, captchas/manual checkpoints, files, and sometimes a long-lived browser session. That cannot be done correctly inside a plain Cloudflare Worker. + +This document defines the architecture and tracks the first implementation slice. + +Current implementation: + +- `packages/browser-runner` provides a local HTTP runner with task state, events, bearer-token protection, and a persistent Playwright profile. +- `pags runner` controls the local runner from the existing CLI. +- `workers/api` stores instance-scoped runtime registrations and proxies task/status/event calls to the registered runner. +- `workers/mcp` exposes runtime tools for registering a runner, probing status, running tasks, approving tasks, cancelling tasks, and reading events. +- The first brain placement target is `brainPlacement = "pags"`: PAGS owns the brain and the local runner acts as a capability/tool executor. +- Browser-resident FAGS-style brains remain a supported later placement, but are not the first PAGS implementation target. + +## Decision Record: Where the Agent Brain Lives + +Status: PAGS-first local runner and runtime registration started. + +Decision owner: not finalized. This recommendation follows the PAS Agent Teams precedent: PAS runs the agent loop in our infrastructure, while user-owned keys can be used for billing. It still needs product/engineering approval for PAGS browser agents. + +Recommendation for the first PAGS implementation: the agent brain and orchestration live in PAGS-hosted infrastructure. The browser runner is a capability executor that can run either on a managed VM or on the user's local machine through a tunnel. + +That means: + +- Managed mode: PAGS assigns a ProAgentStore-managed browser runner VM as the browser/tool executor. +- Local mode: PAGS assigns a user-owned local runner as the browser/tool executor. +- PAGS hosted services own orchestration, auth, billing, runtime assignment, task state, and audit trails. +- A plain Cloudflare Worker can coordinate and route, but it is not the process that runs Playwright. + +PAGS remains the control plane and service owner. It stores the agent template, instance metadata, runtime assignment, task summaries, and audit events. It starts, stops, inspects, and bills tasks. + +The assigned browser runner owns the active browser loop: + +```text +observe browser -> reason/plan -> act in browser -> evaluate result -> request user input/approval when needed +``` + +In managed mode, LLM calls originate from our runner/service path, following the PAS Agent Teams pattern. Billing can be platform-metered or BYO-key, but the loop is ours. + +In local mode, LLM calls should go through PAGS first. A later browser-resident or local/BYO brain can be added as an explicit placement, but it is not the first PAGS implementation target. + +For a text-only server agent, the brain can still run in a Cloudflare Worker. For any agent declaring `runtime.kind = "browser-runner"`, the first implementation routes task orchestration through PAGS and delegates browser actions to the registered runner. + +Why this recommendation exists: + +- PAS already proves the pattern: run our own agent loop in our infrastructure, keep deterministic system stages separate, and meter/BYO keys as needed. +- Managed mode remains the paid product path: reliable, always-on, supportable, auditable, and easier to secure. +- Browser state belongs with the browser process. The managed runner can see DOM, screenshots, downloads, file picker state, and logged-in session directly. +- Local execution is useful for users who prefer not to pay for VM time or want maximum local control, and it exercises the same protocol before managed VM provisioning exists. + +Alternatives considered: + +- Brain in PAGS Worker/service, remote browser in runner. This is the first implementation direction; PAGS owns orchestration and the runner executes browser capabilities. +- Brain in managed runner. This matches PAS's "our loop" principle and supports a paid, reliable browser runtime. +- Brain in local runner. This is acceptable as the no-VM option, but not the default product path. + +This is not a final business decision. It is the current technical recommendation because it lets PAGS ship a generic protocol now, keeps the agent brain connected to PAGS, and preserves managed VM and browser-resident placements for later. + +## Goals + +- Agents can declare that they need browser driving capability. +- A user can hire a managed browser runtime from ProAgentStore. +- A user can alternatively run Playwright on their own machine through Cloudflare Tunnel. +- Hosted PAGS remains the control plane for discovery, subscriptions, config, auth, logs, and MCP. +- Browser execution happens in a runtime environment that can actually run Playwright or equivalent browser automation. +- Sensitive user material such as resumes, cookies, profile data, and job-board sessions stays in the assigned browser runtime. + +## Non-Goals + +- Do not run Playwright inside Cloudflare Workers. +- Do not silently submit applications without an explicit user-controlled approval step. +- Do not require every PAGS agent to use this model. Most server-only agents can keep using Worker/Durable Object runtimes. +- Do not make local-machine runtime the default paid product path. + +## Architecture + +PAGS should separate an agent into three planes: + +1. Control plane +2. Runtime plane +3. Browser execution plane + +### Control Plane + +Hosted by ProAgentStore. + +Responsibilities: + +- Agent registry and marketplace metadata. +- User subscription and instance records. +- MCP tools. +- Runtime capability negotiation. +- Runtime endpoint registration. +- Audit/event logs. +- Secrets references and policy, but not raw browser cookies or local files. +- Optional proxying to a registered runtime endpoint. + +Current components that fit here: + +- `workers/api` +- `workers/mcp` +- store/console UI +- agent metadata such as `agent.json` + +### Runtime Plane + +Runs agent-specific backend logic. + +There are three runtime modes: + +- `hosted-worker`: Cloudflare Worker, for agents that do not need browser automation. +- `managed-browser-runner`: ProAgentStore-managed VM/container with Playwright. +- `local-browser-runner`: user-owned Node service with Playwright, exposed by Cloudflare Tunnel. + +For browser-capable agents, the runtime plane is a managed or local Node process. It exposes an HTTP API that PAGS and MCP can call through a signed task protocol. + +Required runtime endpoints: + +```text +GET /health +GET /capabilities +GET /sessions +POST /sessions +POST /tasks +GET /tasks/:id +POST /tasks/:id/approve +POST /tasks/:id/cancel +GET /events +``` + +Agent-specific endpoints can exist under: + +```text +/agent/* +``` + +PAGS proxied runtime calls include: + +```text +Authorization: Bearer +X-PAGS-Instance-Id: +X-PAGS-Runtime-Placement: local|managed +``` + +The local runner can be started with `--instance-id` to reject calls for the wrong PAGS instance. + +For a job application agent: + +```text +POST /agent/applications +GET /agent/applications/:id +POST /agent/applications/:id/approve-submit +``` + +### Browser Execution Plane + +Runs a browser controlled by the runtime. + +Supported execution targets: + +- Local machine with Playwright and persistent browser profile. +- Managed ProAgentStore VM with Playwright and persistent encrypted storage. + +The browser execution plane owns: + +- Browser profile directory. +- Job-board login sessions. +- Resume files and downloaded artifacts. +- Screenshots/traces. +- Interactive handoff state. + +## Runtime Modes + +### Managed Browser Runner + +Default for paid browser agents. + +ProAgentStore provisions or rents an isolated runtime for the user. The runner hosts: + +- Agent brain. +- Playwright. +- Persistent browser profile. +- Task state. +- Event stream. +- Screenshots/traces/receipts. + +The route is: + +```text +PAGS/MCP -> managed browser runner -> Playwright browser on VM +``` + +The VM should provide: + +- Persistent encrypted disk. +- Playwright browser dependencies. +- Per-user runtime isolation. +- Runtime heartbeat. +- Remote browser viewer or noVNC-style handoff for login/captcha/manual approval. +- Snapshot/stop/delete lifecycle. + +Pros: + +- Always-on option. +- PAGS can provide a managed experience. +- Better reliability for scheduled or long-running jobs. +- Easier to support and bill. +- Aligns with PAS Agent Teams: our system runs the loop. + +Cons: + +- Higher cost. +- More security responsibility. +- Stronger isolation, billing, retention, and deletion policies are required. + +### Local Runtime Through Cloudflare Tunnel + +Fallback for users who do not want to pay for a managed browser runtime. + +The user runs a local service: + +```bash +pnpm start +``` + +The service listens on localhost, for example: + +```text +http://127.0.0.1:49171 +``` + +The user exposes it through Cloudflare Tunnel: + +```bash +cloudflared tunnel --url http://127.0.0.1:49171 +``` + +For production usage, users should use a named tunnel and stable hostname: + +```text +https://serge-job-runner.example.com +``` + +The PAGS console stores the runtime endpoint and verification fingerprint. The local machine stores browser state and files. + +Pros: + +- User keeps sensitive browser sessions and files locally. +- Best fit for personal automation and debugging. +- Lowest runtime cost for the user if they are willing to operate it. + +Cons: + +- User machine must be online. +- User must install Node, Playwright browser dependencies, and Cloudflare Tunnel. +- Reliability depends on the local machine. +- Harder for PAGS to support. + +## Capability Manifest + +Agents should declare runtime requirements in `agent.json`. + +Proposed fields: + +```json +{ + "runtime": { + "kind": "browser-runner", + "defaultPlacement": "managed", + "browser": { + "engine": "playwright", + "persistentProfile": true, + "headful": true, + "manualHandoff": true, + "fileUploads": true, + "downloads": true + }, + "deployment": { + "localTunnel": true, + "managedVm": true, + "hostedWorker": false + } + } +} +``` + +This lets the marketplace and MCP know that the agent cannot run as a normal Worker-only agent. + +## Runtime Registration + +A subscribed instance should have a runtime registration record: + +```json +{ + "instanceId": "inst_123", + "runtimeKind": "browser-runner", + "endpoint": "https://runner.example.com", + "status": "online", + "lastHeartbeatAt": "2026-06-15T00:00:00.000Z", + "capabilities": { + "playwright": true, + "persistentProfile": true, + "manualHandoff": true, + "fileUploads": true + } +} +``` + +MCP tools should support: + +```text +register_instance_runtime +instance_runtime_status +unregister_instance_runtime +run_instance_task +approve_instance_task +cancel_instance_task +instance_task_events +``` + +The existing `chat_with_instance` path can remain for text-only interaction, but browser tasks should use task-oriented tools so state, approval, and events are explicit. + +Development runner commands: + +```bash +pnpm --filter @proagentstore/browser-runner dev -- --port 49171 --token "$PAGS_RUNNER_TOKEN" --instance-id "$PAGS_INSTANCE_ID" +pnpm --filter @proagentstore/cli dev runner status --token "$PAGS_RUNNER_TOKEN" --instance-id "$PAGS_INSTANCE_ID" +pnpm --filter @proagentstore/cli dev runner task --type echo --input '{"ok":true}' --token "$PAGS_RUNNER_TOKEN" --instance-id "$PAGS_INSTANCE_ID" +``` + +PAGS registration and task commands: + +```bash +pnpm --filter @proagentstore/cli dev runner register "$PAGS_INSTANCE_ID" \ + --endpoint-url "$PAGS_RUNNER_ENDPOINT" \ + --runner-token "$PAGS_RUNNER_TOKEN" \ + --pags-token "$PAGS_TOKEN" \ + --probe +pnpm --filter @proagentstore/cli dev runner runtime "$PAGS_INSTANCE_ID" --pags-token "$PAGS_TOKEN" --probe +pnpm --filter @proagentstore/cli dev runner run "$PAGS_INSTANCE_ID" --type echo --input '{"ok":true}' --pags-token "$PAGS_TOKEN" +pnpm --filter @proagentstore/cli dev runner approve-task "$PAGS_INSTANCE_ID" "$PAGS_TASK_ID" --pags-token "$PAGS_TOKEN" +pnpm --filter @proagentstore/cli dev runner cancel-task "$PAGS_INSTANCE_ID" "$PAGS_TASK_ID" --pags-token "$PAGS_TOKEN" +pnpm --filter @proagentstore/cli dev runner task-events "$PAGS_INSTANCE_ID" --pags-token "$PAGS_TOKEN" +``` + +PAGS/MCP registration flow: + +```text +subscribe_agent +register_instance_runtime +instance_runtime_status(probe: true) +run_instance_task +approve_instance_task +cancel_instance_task +instance_task_events +``` + +PAGS API endpoints implemented for subscribed instances: + +```text +POST /v1/instances/:instanceId/runtime +GET /v1/instances/:instanceId/runtime +POST /v1/instances/:instanceId/runtime/heartbeat +GET /v1/instances/:instanceId/runtime/status +DELETE /v1/instances/:instanceId/runtime +POST /v1/instances/:instanceId/tasks +GET /v1/instances/:instanceId/tasks/:taskId +POST /v1/instances/:instanceId/tasks/:taskId/approve +POST /v1/instances/:instanceId/tasks/:taskId/cancel +GET /v1/instances/:instanceId/task-events +``` + +Direct smoke test: + +```bash +curl http://127.0.0.1:49171/health \ + -H "Authorization: Bearer $PAGS_RUNNER_TOKEN" \ + -H "X-PAGS-Instance-Id: $PAGS_INSTANCE_ID" +curl -X POST http://127.0.0.1:49171/tasks \ + -H "Authorization: Bearer $PAGS_RUNNER_TOKEN" \ + -H "X-PAGS-Instance-Id: $PAGS_INSTANCE_ID" \ + -H "Content-Type: application/json" \ + -d '{"type":"echo","input":{"ok":true}}' +``` + +## Authentication + +Runtime endpoints must not be open just because they sit behind a tunnel. + +Minimum requirements: + +- Runtime has a generated shared secret or public key pair. +- PAGS sends signed task requests. +- Runtime verifies request signature and instance ID. +- Runtime rejects unsigned direct public traffic. +- Runtime heartbeat proves the endpoint is still controlled by the user. + +Current MVP: + +- Registration stores the runner bearer token encrypted when `KEY_ENCRYPTION_KEY` is configured. +- PAGS sends the bearer token plus `X-PAGS-Instance-Id` to the runner. +- The local runner can bind to one instance id with `--instance-id`. +- PAGS normalizes runner task payloads and forces approval for `browser.open` before proxying. + +Suggested model: + +- During registration, runtime generates a key pair. +- Runtime sends public key to PAGS through authenticated MCP/API. +- PAGS signs each task request or sends a short-lived task token. +- Runtime validates token audience, instance ID, expiration, and task ID. + +## Browser Task Lifecycle + +Browser tasks should be state machines, not one-shot chat messages. + +Common states: + +```text +queued +running +needs_user_input +needs_approval +blocked +completed +failed +cancelled +``` + +A job application task should usually end at `needs_approval` before final submission. + +Example: + +1. User gives job URL. +2. Runtime opens job URL in Playwright. +3. Runtime extracts job details and application form. +4. Runtime prepares answers and fills fields. +5. If login/captcha/manual step is needed, task becomes `needs_user_input`. +6. Before final submit, task becomes `needs_approval`. +7. User approves through MCP/console. +8. Runtime submits and stores receipt/screenshot. + +## Human Handoff + +Browser agents must support handoff. + +Examples: + +- Job board login. +- MFA. +- Captcha. +- Confirming sensitive application answers. +- Selecting a resume file. +- Reviewing final form before submit. + +The runtime should expose: + +- Current URL. +- Screenshot. +- Task event stream. +- Optional remote browser viewer URL. +- Required user action text. + +The agent should not attempt to bypass captcha or login controls. + +## Job Application Agent Shape + +The job application agent should be browser-runner-first. + +Local runtime responsibilities: + +- Launch persistent Playwright context. +- Visit job URL. +- Detect platform type when possible: Greenhouse, Lever, Workday, Ashby, SmartRecruiters, generic HTML. +- Fill known fields from local profile. +- Upload local resume when approved. +- Generate truthful answers from profile and job description. +- Pause for missing answers. +- Pause before final submission. +- Store application status, screenshot, URL, and receipt locally. + +PAGS control-plane responsibilities: + +- Subscribe user to the agent. +- Register runtime endpoint. +- Start tasks. +- Show task status and events. +- Store non-sensitive task summaries. +- Route MCP calls to runtime. + +## Data Storage + +Local runtime should store: + +- Browser profile. +- Resume files. +- Detailed form contents. +- Screenshots/traces. +- Job-board cookies. + +PAGS should store: + +- Instance ID. +- Runtime endpoint. +- Runtime health status. +- Task summary. +- Timestamps. +- Non-sensitive result metadata. + +For managed VMs, PAGS must define retention and deletion rules before launch. + +## Implementation Plan + +### Phase 1: Protocol and Docs + +- Add this architecture doc. +- Define browser-runner capability fields for `agent.json`. +- Define runtime registration schema. +- Define browser task event schema. +- Decide whether runtime calls are direct from MCP worker to tunnel or proxied through API worker. + +### Phase 2: Local Runner Skeleton + +- Done: create a generic `packages/browser-runner` package. +- Done: Node HTTP server with: + - `/health` + - `/capabilities` + - `/tasks` + - `/tasks/:id` + - `/tasks/:id/approve` + - `/events` +- Done: local JSON storage. +- Done: bearer-token request protection. +- Done: Playwright persistent browser context. +- Cloudflare Tunnel setup docs. + +### Phase 3: PAGS Runtime Registration + +- Done: add DB tables for instance runtime registrations. +- Done: add API endpoints: + - register runtime + - heartbeat + - runtime status + - unregister runtime +- Done: add MCP tools: + - `register_instance_runtime` + - `instance_runtime_status` + - `run_instance_task` + - `approve_instance_task` + - `cancel_instance_task` + - `instance_task_events` +- Add console UI for local tunnel URL setup. + +### Phase 4: Job Application Agent on Browser Runner + +- Convert the job application agent from Worker-first to browser-runner-first. +- Add platform adapters: + - generic HTML form + - Greenhouse + - Lever + - Ashby + - Workday as best-effort with handoff +- Add final-submit approval gate. +- Add local profile/resume management. +- Add screenshots and receipts. + +### Phase 5: Managed VM Runtime + +- Define VM provider abstraction. +- Provision per-user VM. +- Install browser runner and Playwright dependencies. +- Add remote browser handoff. +- Add lifecycle: + - create + - start + - stop + - snapshot + - delete +- Add billing and usage tracking. + +### Phase 6: Hardening + +- Threat model local tunnel and VM runtimes. +- Add request signing and token rotation. +- Add audit trails. +- Add rate limits. +- Add runtime version compatibility checks. +- Add tests for offline runtime, stale tunnel, bad signature, cancelled task, user-input handoff, and approval gate. + +## Open Decisions + +- Direct tunnel calls from MCP/API versus API proxying. +- Named tunnel ownership: user-owned Cloudflare account versus PAGS-managed tunnel. +- Runtime auth: shared secret first or public/private key first. +- Local storage: JSON for MVP versus SQLite from day one. +- Browser viewer implementation for local and VM handoff. +- Whether managed VM is rented per user, per task, or pooled with strong isolation. +- How much task detail PAGS stores versus only local runtime storage. + +## Recommended MVP + +Build the PAGS-first protocol and local runner first, then add managed browser runners behind the same registration and task API. + +Why: + +- It matches PAS: our system runs the loop. +- It lets the runner stay generic: any agent brain connected to PAGS can use the same local capability executor. +- It gives us a cheap end-to-end path before VM provisioning and billing are complete. +- The same runner binary can later run on managed VMs for users who want the paid always-on path. + +MVP scope: + +- PAGS runtime registration and MCP task tools. +- Local Node runner. +- Playwright persistent profile. +- Runtime assignment from PAGS. +- Job application task with generic HTML, Greenhouse, and Lever support. +- Manual handoff and final-submit approval. +- Managed VM mode after the protocol proves out locally. + +Do not make browser-resident local brains the default. Local tunnel is the no-VM executor path; PAGS still owns the first implementation's brain and orchestration. diff --git a/e2e/console-server.mjs b/e2e/console-server.mjs index 8d046fa7..bc254901 100644 --- a/e2e/console-server.mjs +++ b/e2e/console-server.mjs @@ -21,6 +21,12 @@ function resolveStorePath(pathname) { if (cleanPath === "/" || cleanPath === "/console") { return join(storeRoot, "console", "index.html"); } + if (cleanPath === "/docs/browser-runtime") { + return join(storeRoot, "docs", "browser-runtime", "index.html"); + } + if (/^\/agents\/[a-z0-9-]+$/.test(cleanPath)) { + return join(storeRoot, "agents", "detail.html"); + } const relative = normalize(cleanPath.replace(/^\/+/, "")); const target = resolve(storeRoot, relative); diff --git a/e2e/console.spec.ts b/e2e/console.spec.ts index 48ea4af4..fd77ebfd 100644 --- a/e2e/console.spec.ts +++ b/e2e/console.spec.ts @@ -5,6 +5,8 @@ const API = "https://api.proagentstore.online"; const TEST_TOKEN = "test-pags-token"; interface OpsMockOptions { + agents?: Array>; + boardConfig?: Record | null; ops?: Record; verifyStatus?: number; verifyBody?: Record; @@ -65,6 +67,7 @@ async function mockSignedInConsole(page: Page, options: OpsMockOptions = {}) { let verifyCalls = 0; let deployCalls = 0; + const profileUpdates: unknown[] = []; await page.route(`${API}/**`, async (route) => { const url = new URL(route.request().url()); @@ -82,6 +85,10 @@ async function mockSignedInConsole(page: Page, options: OpsMockOptions = {}) { if (path.startsWith("/v1/")) { expect(route.request().headers().authorization).toBe(`Bearer ${TEST_TOKEN}`); } + if (path === "/v1/auth/me" && method === "PUT") { + profileUpdates.push(route.request().postDataJSON()); + return json({ success: true }); + } if (path === "/v1/auth/me") { return json({ id: "user-1", @@ -89,12 +96,13 @@ async function mockSignedInConsole(page: Page, options: OpsMockOptions = {}) { name: "Test User", avatar: "https://example.com/avatar.png", roles: ["user", "creator"], + boardConfig: options.boardConfig ?? null, }); } if (path === "/v1/notifications") return json({ notifications: [], unreadCount: 0 }); if (path === "/v1/agents/my/agents") { return json({ - agents: [ + agents: options.agents ?? [ { id: "agent-1", slug: "ops-agent", @@ -180,6 +188,9 @@ async function mockSignedInConsole(page: Page, options: OpsMockOptions = {}) { get deployCalls() { return deployCalls; }, + get profileUpdates() { + return profileUpdates; + }, }; } @@ -217,6 +228,93 @@ test.describe("ProAgentStore Console smoke", () => { expect(html).toContain("Cloudflare account ID"); expect(html).toContain("triggerDeploy"); }); + + test("signed-in creator console shows an agent status board", async ({ page }) => { + await mockSignedInConsole(page, { + agents: [ + { + id: "draft-agent", + slug: "draft-agent", + name: "Draft Agent", + description: "Still being configured", + category: "general", + visibility: "draft", + status: "inactive", + }, + { + id: "live-agent", + slug: "live-agent", + name: "Live Agent", + description: "Available in the store", + category: "chat", + visibility: "published", + status: "active", + }, + { + id: "error-agent", + slug: "error-agent", + name: "Error Agent", + description: "Needs operator review", + category: "data", + visibility: "draft", + status: "error", + }, + ], + }); + + await page.goto("/"); + + await expect(page.getByText("3 agents across setup, review, live, and attention")).toBeVisible(); + await expect(page.getByText("Setup").first()).toBeVisible(); + await expect(page.getByText("Live").first()).toBeVisible(); + await expect(page.getByText("Attention").first()).toBeVisible(); + await expect( + page.getByLabel("Setup column").getByRole("button", { name: "Open Draft Agent" }), + ).toBeVisible(); + await expect( + page.getByLabel("Live column").getByRole("button", { name: "Open Live Agent" }), + ).toBeVisible(); + await expect( + page.getByLabel("Attention column").getByRole("button", { name: "Open Error Agent" }), + ).toBeVisible(); + await expect( + page.getByLabel("Setup column").getByRole("button", { name: "Open Error Agent" }), + ).toHaveCount(0); + }); + + test("signed-in creator can save a custom agent board config", async ({ page }) => { + const mock = await mockSignedInConsole(page); + await page.goto("/"); + + await page.getByRole("button", { name: "Configure Board" }).click(); + const customConfig = { + summary: "build and shipped", + columns: [ + { + id: "build", + title: "Build", + color: "var(--yellow)", + statuses: ["inactive"], + visibilities: ["draft"], + }, + { + id: "shipped", + title: "Shipped", + color: "var(--green)", + statuses: ["active"], + visibilities: ["published"], + catchAll: true, + }, + ], + }; + await page.locator("#board-config-json").fill(JSON.stringify(customConfig, null, 2)); + await page.getByRole("button", { name: "Save Board" }).click(); + + await expect(page.getByText("1 agent across build and shipped")).toBeVisible(); + await expect(page.getByText("Build").first()).toBeVisible(); + expect(mock.profileUpdates).toHaveLength(1); + expect(mock.profileUpdates[0]).toMatchObject({ board_config: customConfig }); + }); }); test.describe("ProAgentStore skill discovery", () => { @@ -272,6 +370,74 @@ test.describe("ProAgentStore skill discovery", () => { }); }); +test.describe("ProAgentStore agent detail pages", () => { + test("job application assistant renders as a public agent dashboard", async ({ page }) => { + await page.route(`${API}/v1/public/agents/job-application-assistant`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + id: "job-application-assistant", + slug: "job-application-assistant", + name: "Job Application Assistant", + description: + "Turns a job URL into a tailored application packet and submits only after explicit confirmation.", + category: "productivity", + store_type: "agent", + model: "@cf/meta/llama-3.3-70b-instruct-fp8-fast", + created_at: "2026-06-15T00:00:00Z", + subscriber_count: 0, + }), + }), + ); + await page.route("https://mcp.proagentstore.online/health", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ok: true, tools: 28 }), + }), + ); + await page.route( + "https://raw.githubusercontent.com/ProAgentStore/job-application-assistant/main/README.md", + (route) => + route.fulfill({ + status: 200, + contentType: "text/plain", + body: "# Job Application Assistant\n\nPrepare and submit job applications safely.", + }), + ); + + await page.goto("/agents/job-application-assistant/"); + + await expect( + page.getByRole("heading", { name: "Job Application Assistant" }), + ).toBeVisible(); + await expect(page.locator("#a-category")).toHaveText("productivity"); + await expect(page.locator("#a-health-pill")).toHaveText("online"); + await expect(page.getByText("MCP online with 28 tools")).toBeVisible(); + await expect(page.locator("#api-chat")).toContainText( + "/v1/public/agents/job-application-assistant/try", + ); + await expect(page.locator("#readme-summary")).toContainText( + "Prepare and submit job applications safely.", + ); + }); +}); + +test.describe("ProAgentStore architecture docs", () => { + test("browser runtime docs show the managed-runner architecture", async ({ page }) => { + await page.goto("/docs/browser-runtime/"); + + await expect( + page.getByRole("heading", { name: "Browser-Capable Agent Runtime" }), + ).toBeVisible(); + await expect(page.getByText("PAS precedent: our loop")).toBeVisible(); + await expect(page.getByText("Managed Browser Runner").first()).toBeVisible(); + await expect(page.getByText("Local Browser Runner").first()).toBeVisible(); + await expect(page.getByText("MVP recommendation")).toBeVisible(); + }); +}); + test.describe("ProAgentStore live API smoke", () => { test("providers include Cloudflare Workers AI", async ({ request }) => { const res = await request.get( @@ -299,8 +465,8 @@ test.describe("ProAgentStore authenticated Console", () => { await page.goto("/"); await expect(page.getByText("Agents you've built")).toBeVisible(); - await page.locator("#agents-list .agent-card", { hasText: "Ops Agent" }).click(); - await page.getByRole("button", { name: "Ops" }).click(); + await page.getByRole("button", { name: "Open Ops Agent" }).click(); + await page.getByRole("button", { name: "Ops", exact: true }).click(); await expect(page.getByText("Ready: user-owned Cloudflare Workers AI")).toBeVisible(); await expect(page.getByText("API:")).toBeVisible(); @@ -319,8 +485,8 @@ test.describe("ProAgentStore authenticated Console", () => { page.on("dialog", (dialog) => dialog.accept()); await page.goto("/"); - await page.locator("#agents-list .agent-card", { hasText: "Ops Agent" }).click(); - await page.getByRole("button", { name: "Ops" }).click(); + await page.getByRole("button", { name: "Open Ops Agent" }).click(); + await page.getByRole("button", { name: "Ops", exact: true }).click(); await page.getByRole("button", { name: "Verify Key" }).click(); await expect .poll(() => calls.verifyCalls) @@ -382,8 +548,8 @@ test.describe("ProAgentStore authenticated Console", () => { }); await page.goto("/"); - await page.locator("#agents-list .agent-card", { hasText: "Ops Agent" }).click(); - await page.getByRole("button", { name: "Ops" }).click(); + await page.getByRole("button", { name: "Open Ops Agent" }).click(); + await page.getByRole("button", { name: "Ops", exact: true }).click(); await expect(page.locator("#tab-ops img")).toHaveCount(0); await expect(page.locator("#tab-ops script")).toHaveCount(0); @@ -404,8 +570,8 @@ test.describe("ProAgentStore authenticated Console", () => { page.on("dialog", (dialog) => dialog.dismiss()); await page.goto("/"); - await page.locator("#agents-list .agent-card", { hasText: "Ops Agent" }).click(); - await page.getByRole("button", { name: "Ops" }).click(); + await page.getByRole("button", { name: "Open Ops Agent" }).click(); + await page.getByRole("button", { name: "Ops", exact: true }).click(); await page.getByRole("button", { name: "Verify Key" }).click(); await page.getByRole("button", { name: "Deploy" }).click(); diff --git a/packages/browser-runner/README.md b/packages/browser-runner/README.md new file mode 100644 index 00000000..73242cec --- /dev/null +++ b/packages/browser-runner/README.md @@ -0,0 +1,54 @@ +# ProAgentStore Browser Runner + +Local capability runner for ProAgentStore agents. + +The PAGS brain stays in the hosted control plane. This process runs on the user's machine and exposes local capabilities such as Playwright, screenshots, downloads, file upload paths, and approval-gated actions. + +```bash +pnpm --filter @proagentstore/browser-runner dev -- --port 49171 +``` + +The runner listens on `127.0.0.1` by default. Use `--token` and `--instance-id` when exposing it through Cloudflare Tunnel. PAGS includes `Authorization: Bearer ` and `X-PAGS-Instance-Id` on proxied task calls. + +```bash +pags-browser-runner --port 49171 --token "$PAGS_RUNNER_TOKEN" --instance-id "$PAGS_INSTANCE_ID" +``` + +Local CLI calls to an instance-bound runner need the same instance id: + +```bash +pags runner status --token "$PAGS_RUNNER_TOKEN" --instance-id "$PAGS_INSTANCE_ID" +``` + +Register the runner with PAGS after exposing it through a stable tunnel: + +```bash +pags runner register "$PAGS_INSTANCE_ID" \ + --endpoint-url "$PAGS_RUNNER_ENDPOINT" \ + --runner-token "$PAGS_RUNNER_TOKEN" \ + --pags-token "$PAGS_TOKEN" \ + --probe +pags runner runtime "$PAGS_INSTANCE_ID" --pags-token "$PAGS_TOKEN" --probe +pags runner run "$PAGS_INSTANCE_ID" --type echo --input '{"ok":true}' --pags-token "$PAGS_TOKEN" +``` + +Initial protocol: + +```text +GET /health +GET /capabilities +GET /sessions +POST /sessions +POST /tasks +GET /tasks/:id +POST /tasks/:id/approve +POST /tasks/:id/cancel +GET /events +``` + +Task types in this first version: + +- `echo`: smoke-test task. +- `browser.open`: opens a URL in a persistent Playwright profile. + +The runner is intentionally generic. Job-application behavior should be implemented as an adapter on top of this protocol rather than inside the core runner. diff --git a/packages/browser-runner/package.json b/packages/browser-runner/package.json new file mode 100644 index 00000000..1a96620c --- /dev/null +++ b/packages/browser-runner/package.json @@ -0,0 +1,38 @@ +{ + "name": "@proagentstore/browser-runner", + "version": "0.1.0", + "description": "Local capability runner for ProAgentStore browser and desktop tasks", + "license": "MIT", + "type": "module", + "bin": { + "pags-browser-runner": "dist/index.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc", + "dev": "tsx src/index.ts", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "playwright": "^1.60.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.21.0", + "typescript": "^5.7.0", + "vitest": "^3.2.4" + }, + "sideEffects": false +} diff --git a/packages/browser-runner/src/index.ts b/packages/browser-runner/src/index.ts new file mode 100644 index 00000000..8017a59f --- /dev/null +++ b/packages/browser-runner/src/index.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import { homedir } from "node:os"; +import { join } from "node:path"; +import { startRunnerServer } from "./server.js"; +import type { RunnerConfig } from "./types.js"; + +function arg(name: string, fallback?: string): string | undefined { + const index = process.argv.indexOf(name); + if (index === -1) return fallback; + return process.argv[index + 1] || fallback; +} + +function flag(name: string): boolean { + return process.argv.includes(name); +} + +function configFromArgs(): RunnerConfig { + const dataDir = + arg("--data-dir") || + process.env.PAGS_RUNNER_DATA_DIR || + join(homedir(), ".config", "proagentstore", "browser-runner"); + return { + host: arg("--host", process.env.PAGS_RUNNER_HOST || "127.0.0.1") || "127.0.0.1", + port: Number(arg("--port", process.env.PAGS_RUNNER_PORT || "49171")), + dataDir, + token: arg("--token", process.env.PAGS_RUNNER_TOKEN), + instanceId: arg("--instance-id", process.env.PAGS_INSTANCE_ID), + headless: flag("--headless") || process.env.PAGS_RUNNER_HEADLESS === "1", + }; +} + +if (flag("--help") || flag("-h")) { + process.stdout.write(`ProAgentStore browser runner + +Usage: + pags-browser-runner [--host 127.0.0.1] [--port 49171] [--data-dir path] [--token token] [--instance-id id] [--headless] + +Endpoints: + GET /health + GET /capabilities + POST /tasks + GET /tasks/:id + POST /tasks/:id/approve + POST /tasks/:id/cancel + GET /events +`); + process.exit(0); +} + +const config = configFromArgs(); +const started = await startRunnerServer(config); +process.stdout.write(`PAGS browser runner listening at ${started.url}\n`); +process.stdout.write(`Data dir: ${config.dataDir}\n`); +process.stdout.write(`Brain placement: PAGS; runner role: tool-executor\n`); +if (config.token) process.stdout.write("Auth: bearer token required\n"); +if (config.instanceId) process.stdout.write(`Instance binding: ${config.instanceId}\n`); + +const shutdown = async () => { + await started.close(); + process.exit(0); +}; + +process.on("SIGINT", () => void shutdown()); +process.on("SIGTERM", () => void shutdown()); diff --git a/packages/browser-runner/src/runner.test.ts b/packages/browser-runner/src/runner.test.ts new file mode 100644 index 00000000..696a82db --- /dev/null +++ b/packages/browser-runner/src/runner.test.ts @@ -0,0 +1,81 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { LocalRunner } from "./runner.js"; +import { RunnerStore } from "./store.js"; + +describe("LocalRunner", () => { + let dir: string; + let runner: LocalRunner; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "pags-runner-")); + runner = new LocalRunner({ + host: "127.0.0.1", + port: 0, + dataDir: dir, + headless: true, + }); + }); + + afterEach(async () => { + await runner.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + it("advertises PAGS brain placement and local capabilities", () => { + expect(runner.capabilities()).toMatchObject({ + runtime: "local-browser-runner", + brainPlacement: "pags", + runnerRole: "tool-executor", + }); + expect(runner.capabilities().capabilities).toContain("browser.playwright"); + }); + + it("runs echo tasks without approval", async () => { + const task = runner.createTask({ + type: "echo", + input: { ok: true }, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + const saved = runner.store.getTask(task.id); + expect(saved?.status).toBe("completed"); + expect(saved?.output).toEqual({ ok: true }); + }); + + it("holds approval-gated tasks until approved", async () => { + const task = runner.createTask({ + type: "echo", + input: { approved: true }, + requiresApproval: true, + approvalPrompt: "Approve echo", + }); + expect(task.status).toBe("needs_approval"); + expect(runner.store.getTask(task.id)?.status).toBe("needs_approval"); + const approved = await runner.approveTask(task.id); + expect(approved.status).toBe("completed"); + expect(approved.output).toEqual({ approved: true }); + }); + + it("requires approval for browser.open tasks", () => { + const task = runner.createTask({ + type: "browser.open", + input: { url: "https://example.com" }, + }); + expect(task.status).toBe("needs_approval"); + expect(task.requiresApproval).toBe(true); + }); + + it("does not share empty store arrays across fresh data directories", () => { + const otherDir = mkdtempSync(join(tmpdir(), "pags-runner-other-")); + try { + const first = new RunnerStore(dir); + const second = new RunnerStore(otherDir); + first.createSession(); + expect(second.listSessions()).toEqual([]); + } finally { + rmSync(otherDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/browser-runner/src/runner.ts b/packages/browser-runner/src/runner.ts new file mode 100644 index 00000000..45943fa4 --- /dev/null +++ b/packages/browser-runner/src/runner.ts @@ -0,0 +1,222 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { BrowserContext } from "playwright"; +import { RunnerStore } from "./store.js"; +import type { + CreateTaskRequest, + RunnerCapability, + RunnerConfig, + RunnerTask, +} from "./types.js"; + +const CAPABILITIES: RunnerCapability[] = [ + "browser.playwright", + "browser.screenshot", + "downloads", + "file.upload", + "human.approval", +]; +const APPROVAL_REQUIRED_TASKS = new Set(["browser.open"]); + +export class RunnerInputError extends Error { + readonly status = 400; +} + +export class LocalRunner { + private browserContext: BrowserContext | null = null; + readonly store: RunnerStore; + + constructor(readonly config: RunnerConfig) { + mkdirSync(config.dataDir, { recursive: true }); + this.store = new RunnerStore(config.dataDir); + } + + capabilities() { + return { + runtime: "local-browser-runner", + brainPlacement: "pags", + runnerRole: "tool-executor", + capabilities: CAPABILITIES, + taskTypes: ["echo", "browser.open"], + approvalRequiredFor: ["browser.open"], + }; + } + + createTask(request: CreateTaskRequest): RunnerTask { + const normalized = normalizeCreateTaskRequest(request); + const now = new Date().toISOString(); + const requiresApproval = + normalized.requiresApproval || APPROVAL_REQUIRED_TASKS.has(normalized.type); + const task: RunnerTask = { + id: `task_${crypto.randomUUID()}`, + type: normalized.type, + status: requiresApproval ? "needs_approval" : "queued", + input: normalized.input, + requiresApproval, + approval: requiresApproval + ? { + prompt: normalized.approvalPrompt || `Approve task ${normalized.type}`, + } + : undefined, + createdAt: now, + updatedAt: now, + }; + this.store.putTask(task); + this.store.addEvent({ + taskId: task.id, + type: "task.created", + message: `Task created: ${task.type}`, + data: { status: task.status }, + }); + if (task.status === "queued") void this.runTask(task.id); + return task; + } + + async approveTask(id: string): Promise { + const task = this.requireTask(id); + if (task.status !== "needs_approval") { + throw new Error(`Task is not waiting for approval: ${task.status}`); + } + task.status = "queued"; + task.updatedAt = new Date().toISOString(); + task.approval = { + prompt: task.approval?.prompt || `Approve task ${task.type}`, + approvedAt: task.updatedAt, + }; + this.store.putTask(task); + this.store.addEvent({ + taskId: task.id, + type: "task.approved", + message: `Task approved: ${task.type}`, + }); + await this.runTask(task.id); + return this.requireTask(id); + } + + cancelTask(id: string): RunnerTask { + const task = this.requireTask(id); + if (task.status === "completed" || task.status === "failed") return task; + task.status = "cancelled"; + task.updatedAt = new Date().toISOString(); + task.completedAt = task.updatedAt; + this.store.putTask(task); + this.store.addEvent({ + taskId: task.id, + type: "task.cancelled", + message: `Task cancelled: ${task.type}`, + }); + return task; + } + + async close(): Promise { + await this.browserContext?.close(); + this.browserContext = null; + } + + private async runTask(id: string): Promise { + const task = this.requireTask(id); + if (task.status !== "queued") return; + task.status = "running"; + task.updatedAt = new Date().toISOString(); + this.store.putTask(task); + this.store.addEvent({ + taskId: task.id, + type: "task.running", + message: `Task running: ${task.type}`, + }); + + try { + const output = await this.execute(task); + task.status = "completed"; + task.output = output; + task.updatedAt = new Date().toISOString(); + task.completedAt = task.updatedAt; + this.store.putTask(task); + this.store.addEvent({ + taskId: task.id, + type: "task.completed", + message: `Task completed: ${task.type}`, + data: output, + }); + } catch (error) { + task.status = "failed"; + task.error = error instanceof Error ? error.message : String(error); + task.updatedAt = new Date().toISOString(); + task.completedAt = task.updatedAt; + this.store.putTask(task); + this.store.addEvent({ + taskId: task.id, + type: "task.failed", + message: task.error, + }); + } + } + + private async execute(task: RunnerTask): Promise { + if (task.type === "echo") { + return task.input; + } + if (task.type === "browser.open") { + const url = String(task.input.url || ""); + if (!url || !/^https?:\/\//.test(url)) { + throw new Error("browser.open requires an http(s) url"); + } + const context = await this.getBrowserContext(); + const page = context.pages()[0] || (await context.newPage()); + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 }); + return { + url: page.url(), + title: await page.title(), + }; + } + throw new Error(`Unknown task type: ${task.type}`); + } + + private requireTask(id: string): RunnerTask { + const task = this.store.getTask(id); + if (!task) throw new Error(`Task not found: ${id}`); + return task; + } + + private async getBrowserContext(): Promise { + if (this.browserContext) return this.browserContext; + const profileDir = join(this.config.dataDir, "browser-profile"); + const downloadsPath = join(this.config.dataDir, "downloads"); + mkdirSync(profileDir, { recursive: true }); + mkdirSync(downloadsPath, { recursive: true }); + const playwright = await import("playwright").catch((error) => { + throw new Error( + `Playwright is not installed. Run pnpm install in the PAGS platform repo. ${error instanceof Error ? error.message : String(error)}`, + ); + }); + this.browserContext = await playwright.chromium.launchPersistentContext(profileDir, { + headless: this.config.headless, + acceptDownloads: true, + downloadsPath, + }); + return this.browserContext; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeCreateTaskRequest(request: CreateTaskRequest): Required { + if (!isRecord(request) || typeof request.type !== "string" || !request.type.trim()) { + throw new RunnerInputError("task type required"); + } + return { + type: request.type.trim().slice(0, 120), + input: isRecord(request.input) ? request.input : {}, + requiresApproval: request.requiresApproval === true, + approvalPrompt: typeof request.approvalPrompt === "string" + ? request.approvalPrompt.trim().slice(0, 500) + : "", + }; +} + +export function fileUrl(path: string): string { + return pathToFileURL(path).toString(); +} diff --git a/packages/browser-runner/src/server.test.ts b/packages/browser-runner/src/server.test.ts new file mode 100644 index 00000000..6be41ca9 --- /dev/null +++ b/packages/browser-runner/src/server.test.ts @@ -0,0 +1,101 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { startRunnerServer } from "./server.js"; + +describe("runner server", () => { + let dir: string; + let close: () => Promise; + let url: string; + + beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), "pags-runner-server-")); + const started = await startRunnerServer({ + host: "127.0.0.1", + port: 0, + dataDir: dir, + token: "secret", + instanceId: "inst-1", + headless: true, + }); + close = started.close; + url = started.url; + }); + + afterEach(async () => { + await close(); + rmSync(dir, { recursive: true, force: true }); + }); + + it("requires auth when token is configured", async () => { + const res = await fetch(`${url}/health`); + expect(res.status).toBe(401); + }); + + it("creates and reads tasks", async () => { + const created = await fetch(`${url}/tasks`, { + method: "POST", + headers: { + Authorization: "Bearer secret", + "X-PAGS-Instance-Id": "inst-1", + "Content-Type": "application/json", + }, + body: JSON.stringify({ type: "echo", input: { value: 1 } }), + }); + expect(created.status).toBe(202); + const task = (await created.json()) as { id: string }; + await new Promise((resolve) => setTimeout(resolve, 20)); + const read = await fetch(`${url}/tasks/${task.id}`, { + headers: { + Authorization: "Bearer secret", + "X-PAGS-Instance-Id": "inst-1", + }, + }); + const saved = (await read.json()) as { status: string; output: unknown }; + expect(saved.status).toBe("completed"); + expect(saved.output).toEqual({ value: 1 }); + }); + + it("returns 400 for invalid JSON request bodies", async () => { + const res = await fetch(`${url}/tasks`, { + method: "POST", + headers: { + Authorization: "Bearer secret", + "X-PAGS-Instance-Id": "inst-1", + "Content-Type": "application/json", + }, + body: "{", + }); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toMatchObject({ + error: "Request body must be valid JSON", + }); + }); + + it("returns 400 for malformed task requests", async () => { + const res = await fetch(`${url}/tasks`, { + method: "POST", + headers: { + Authorization: "Bearer secret", + "X-PAGS-Instance-Id": "inst-1", + "Content-Type": "application/json", + }, + body: JSON.stringify({ input: { value: 1 } }), + }); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toMatchObject({ + error: "task type required", + }); + }); + + it("rejects the wrong PAGS instance id when bound", async () => { + const res = await fetch(`${url}/health`, { + headers: { + Authorization: "Bearer secret", + "X-PAGS-Instance-Id": "inst-2", + }, + }); + expect(res.status).toBe(401); + }); +}); diff --git a/packages/browser-runner/src/server.ts b/packages/browser-runner/src/server.ts new file mode 100644 index 00000000..67dec3ce --- /dev/null +++ b/packages/browser-runner/src/server.ts @@ -0,0 +1,145 @@ +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import type { AddressInfo } from "node:net"; +import { URL } from "node:url"; +import { LocalRunner, RunnerInputError } from "./runner.js"; +import type { CreateTaskRequest, RunnerConfig } from "./types.js"; + +export function createRunnerServer(runner: LocalRunner) { + return createServer(async (req, res) => { + try { + if (!authorize(req, runner.config)) { + return json(res, 401, { error: "Unauthorized" }); + } + await route(runner, req, res); + } catch (error) { + const status = error instanceof RunnerInputError ? error.status : 500; + json(res, status, { + error: error instanceof Error ? error.message : String(error), + }); + } + }); +} + +export async function startRunnerServer(config: RunnerConfig): Promise<{ + runner: LocalRunner; + close: () => Promise; + url: string; +}> { + const runner = new LocalRunner(config); + const server = createRunnerServer(runner); + await new Promise((resolve) => { + server.listen(config.port, config.host, resolve); + }); + const address = server.address() as AddressInfo; + const actualPort = address.port; + return { + runner, + url: `http://${config.host}:${actualPort}`, + async close() { + await runner.close(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }, + }; +} + +async function route(runner: LocalRunner, req: IncomingMessage, res: ServerResponse) { + const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`); + const path = url.pathname.replace(/\/$/, "") || "/"; + + if (req.method === "GET" && path === "/health") { + return json(res, 200, { + ok: true, + service: "proagentstore-browser-runner", + brainPlacement: "pags", + instanceId: runner.config.instanceId, + }); + } + + if (req.method === "GET" && path === "/capabilities") { + return json(res, 200, runner.capabilities()); + } + + if (req.method === "GET" && path === "/sessions") { + return json(res, 200, { sessions: runner.store.listSessions() }); + } + + if (req.method === "POST" && path === "/sessions") { + return json(res, 201, runner.store.createSession()); + } + + if (req.method === "GET" && path === "/tasks") { + return json(res, 200, { tasks: runner.store.listTasks() }); + } + + if (req.method === "POST" && path === "/tasks") { + const body = await readJson(req); + return json(res, 202, runner.createTask(body)); + } + + const taskMatch = path.match(/^\/tasks\/([^/]+)$/); + if (req.method === "GET" && taskMatch) { + const task = runner.store.getTask(taskMatch[1]); + if (!task) return json(res, 404, { error: "Task not found" }); + return json(res, 200, task); + } + + const approveMatch = path.match(/^\/tasks\/([^/]+)\/approve$/); + if (req.method === "POST" && approveMatch) { + return json(res, 200, await runner.approveTask(approveMatch[1])); + } + + const cancelMatch = path.match(/^\/tasks\/([^/]+)\/cancel$/); + if (req.method === "POST" && cancelMatch) { + return json(res, 200, runner.cancelTask(cancelMatch[1])); + } + + if (req.method === "GET" && path === "/events") { + const limit = clampLimit(url.searchParams.get("limit"), 100, 500); + return json(res, 200, { events: runner.store.listEvents(limit) }); + } + + return json(res, 404, { error: "Not found" }); +} + +function authorize(req: IncomingMessage, config: RunnerConfig): boolean { + const token = config.token; + if (config.instanceId && req.headers["x-pags-instance-id"] !== config.instanceId) { + return false; + } + if (!token) return true; + const auth = req.headers.authorization || ""; + const headerToken = req.headers["x-pags-runner-token"]; + return auth === `Bearer ${token}` || headerToken === token; +} + +async function readJson(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + const raw = Buffer.concat(chunks).toString("utf-8"); + if (!raw) return {} as T; + try { + return JSON.parse(raw) as T; + } catch { + throw new RunnerInputError("Request body must be valid JSON"); + } +} + +function clampLimit(value: string | null, fallback: number, max: number): number { + const parsed = Number(value || fallback); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(1, Math.min(max, Math.trunc(parsed))); +} + +function json(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "X-Content-Type-Options": "nosniff", + }); + res.end(JSON.stringify(body)); +} diff --git a/packages/browser-runner/src/store.ts b/packages/browser-runner/src/store.ts new file mode 100644 index 00000000..317b62f7 --- /dev/null +++ b/packages/browser-runner/src/store.ts @@ -0,0 +1,97 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import type { RunnerEvent, RunnerSession, RunnerTask } from "./types.js"; + +interface StoreFile { + sessions: RunnerSession[]; + tasks: RunnerTask[]; + events: RunnerEvent[]; +} + +function emptyStore(): StoreFile { + return { + sessions: [], + tasks: [], + events: [], + }; +} + +export class RunnerStore { + private readonly filePath: string; + + constructor(dataDir: string) { + this.filePath = join(dataDir, "runner-store.json"); + } + + listSessions(): RunnerSession[] { + return this.read().sessions; + } + + createSession(): RunnerSession { + const data = this.read(); + const now = new Date().toISOString(); + const session: RunnerSession = { + id: `session_${crypto.randomUUID()}`, + createdAt: now, + status: "active", + }; + data.sessions.unshift(session); + this.write(data); + return session; + } + + listTasks(): RunnerTask[] { + return this.read().tasks; + } + + getTask(id: string): RunnerTask | null { + return this.read().tasks.find((task) => task.id === id) || null; + } + + putTask(task: RunnerTask): RunnerTask { + const data = this.read(); + const index = data.tasks.findIndex((row) => row.id === task.id); + if (index === -1) data.tasks.unshift(task); + else data.tasks[index] = task; + this.write(data); + return task; + } + + addEvent(event: Omit): RunnerEvent { + const data = this.read(); + const row: RunnerEvent = { + ...event, + id: `event_${crypto.randomUUID()}`, + createdAt: new Date().toISOString(), + }; + data.events.unshift(row); + data.events = data.events.slice(0, 500); + this.write(data); + return row; + } + + listEvents(limit = 100): RunnerEvent[] { + return this.read().events.slice(0, limit); + } + + private read(): StoreFile { + if (!existsSync(this.filePath)) return emptyStore(); + try { + const parsed = JSON.parse(readFileSync(this.filePath, "utf-8")) as Partial; + return { + sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [], + tasks: Array.isArray(parsed.tasks) ? parsed.tasks : [], + events: Array.isArray(parsed.events) ? parsed.events : [], + }; + } catch { + return emptyStore(); + } + } + + private write(data: StoreFile): void { + mkdirSync(dirname(this.filePath), { recursive: true }); + writeFileSync(this.filePath, `${JSON.stringify(data, null, 2)}\n`, { + mode: 0o600, + }); + } +} diff --git a/packages/browser-runner/src/types.ts b/packages/browser-runner/src/types.ts new file mode 100644 index 00000000..b780e381 --- /dev/null +++ b/packages/browser-runner/src/types.ts @@ -0,0 +1,63 @@ +export type RunnerCapability = + | "browser.playwright" + | "browser.screenshot" + | "downloads" + | "file.upload" + | "human.approval"; + +export type TaskStatus = + | "queued" + | "running" + | "needs_approval" + | "blocked" + | "completed" + | "failed" + | "cancelled"; + +export interface RunnerConfig { + host: string; + port: number; + dataDir: string; + token?: string; + instanceId?: string; + headless: boolean; +} + +export interface RunnerSession { + id: string; + createdAt: string; + status: "active" | "closed"; +} + +export interface RunnerTask { + id: string; + type: string; + status: TaskStatus; + input: Record; + output?: unknown; + error?: string; + requiresApproval: boolean; + approval?: { + prompt: string; + approvedAt?: string; + }; + createdAt: string; + updatedAt: string; + completedAt?: string; +} + +export interface RunnerEvent { + id: string; + taskId?: string; + type: string; + message: string; + createdAt: string; + data?: unknown; +} + +export interface CreateTaskRequest { + type: string; + input?: Record; + requiresApproval?: boolean; + approvalPrompt?: string; +} diff --git a/packages/browser-runner/tsconfig.json b/packages/browser-runner/tsconfig.json new file mode 100644 index 00000000..0fe5acfb --- /dev/null +++ b/packages/browser-runner/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/cli/src/commands/runner.test.ts b/packages/cli/src/commands/runner.test.ts new file mode 100644 index 00000000..615b5352 --- /dev/null +++ b/packages/cli/src/commands/runner.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + apiPathSegment, + buildRuntimeRegistrationBody, + buildRunnerArgs, + createRunnerCommand, + pagsApiBase, + pagsHeaders, + requestPags, + requestRunner, + runnerBaseUrl, + runnerHeaders, + runnerRequestHeaders, +} from "./runner.js"; + +describe("runner command helpers", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it("builds runner process args", () => { + expect( + buildRunnerArgs({ + host: "127.0.0.1", + port: "49171", + token: "secret", + instanceId: "inst-1", + headless: true, + }), + ).toEqual([ + "--host", + "127.0.0.1", + "--port", + "49171", + "--token", + "secret", + "--instance-id", + "inst-1", + "--headless", + ]); + }); + + it("normalizes runner URL", () => { + expect(runnerBaseUrl("http://127.0.0.1:49171/")).toBe("http://127.0.0.1:49171"); + expect(runnerBaseUrl(" ")).toBe("http://127.0.0.1:49171"); + }); + + it("normalizes PAGS API URL", () => { + expect(pagsApiBase("https://api.proagentstore.online/")).toBe( + "https://api.proagentstore.online", + ); + }); + + it("creates bearer headers when token is present", () => { + expect(runnerHeaders("abc")).toEqual({ Authorization: "Bearer abc" }); + expect(pagsHeaders("pags")).toEqual({ Authorization: "Bearer pags" }); + }); + + it("creates instance-bound request headers", () => { + expect(runnerRequestHeaders({ token: "abc", instanceId: "inst-1" })).toEqual({ + Authorization: "Bearer abc", + "X-PAGS-Instance-Id": "inst-1", + }); + }); + + it("encodes API path segments", () => { + expect(apiPathSegment("inst/1")).toBe("inst%2F1"); + }); + + it("builds PAGS runtime registration body", () => { + expect( + buildRuntimeRegistrationBody( + { + endpointUrl: " https://runner.example.com ", + runnerToken: " runner-secret ", + placement: "managed", + runnerVersion: " 0.1.0 ", + }, + ["browser.playwright"], + ), + ).toEqual({ + endpointUrl: "https://runner.example.com", + token: "runner-secret", + placement: "managed", + capabilities: ["browser.playwright"], + runnerVersion: "0.1.0", + }); + }); + + it("requires a PAGS token for PAGS API requests", async () => { + await expect(requestPags("GET", "/v1/instances/inst-1/runtime", {})).rejects.toThrow( + "PAGS token required", + ); + }); + + it("returns readable errors for non-JSON runner responses", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("runner is down", { status: 503, statusText: "Unavailable" })), + ); + + await expect(requestRunner("GET", "/health", {})).rejects.toThrow("503 runner is down"); + }); + + it("sends local cancel command to the encoded runner task path", async () => { + const fetchMock = vi.fn(async () => + Response.json({ + id: "task/1", + status: "cancelled", + }), + ); + vi.stubGlobal("fetch", fetchMock); + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + const command = createRunnerCommand(); + await command.parseAsync([ + "node", + "runner", + "cancel", + "task/1", + "--url", + "http://127.0.0.1:49171", + "--token", + "runner-token", + "--instance-id", + "inst-1", + ]); + + expect(fetchMock).toHaveBeenCalledWith( + "http://127.0.0.1:49171/tasks/task%2F1/cancel", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer runner-token", + "X-PAGS-Instance-Id": "inst-1", + }), + }), + ); + expect(writeSpy).toHaveBeenCalled(); + }); + + it("sends PAGS approve-task command to encoded instance and task paths", async () => { + const fetchMock = vi.fn(async () => + Response.json({ + id: "task/1", + status: "completed", + }), + ); + vi.stubGlobal("fetch", fetchMock); + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + const command = createRunnerCommand(); + await command.parseAsync([ + "node", + "runner", + "approve-task", + "inst/1", + "task/1", + "--api-base", + "https://api.example.com/", + "--pags-token", + "pags-token", + ]); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/v1/instances/inst%2F1/tasks/task%2F1/approve", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer pags-token", + }), + }), + ); + expect(writeSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/commands/runner.ts b/packages/cli/src/commands/runner.ts new file mode 100644 index 00000000..2bd4a57d --- /dev/null +++ b/packages/cli/src/commands/runner.ts @@ -0,0 +1,444 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { Command } from "commander"; +import { writeError, writeLine } from "../output.js"; + +interface RunnerStartOptions { + host?: string; + port?: string; + dataDir?: string; + token?: string; + instanceId?: string; + headless?: boolean; +} + +interface RunnerRequestOptions { + url?: string; + token?: string; + instanceId?: string; +} + +interface PagsRequestOptions { + apiBase?: string; + pagsToken?: string; +} + +interface RuntimeRegisterOptions extends PagsRequestOptions, RunnerRequestOptions { + endpointUrl: string; + runnerToken?: string; + placement?: string; + runnerVersion?: string; + capability?: string[]; + probe?: boolean; +} + +export const runnerCommand = createRunnerCommand(); + +export function runnerBaseUrl(url?: string): string { + return (clean(url) || clean(process.env.PAGS_RUNNER_URL) || "http://127.0.0.1:49171").replace(/\/$/, ""); +} + +export function pagsApiBase(url?: string): string { + return (clean(url) || clean(process.env.PAGS_API_BASE) || "https://api.proagentstore.online").replace(/\/$/, ""); +} + +export function pagsHeaders(token?: string): Record { + const resolved = clean(token) || clean(process.env.PAGS_TOKEN); + return resolved ? { Authorization: `Bearer ${resolved}` } : {}; +} + +export function runnerHeaders(token?: string): Record { + const resolved = clean(token) || clean(process.env.PAGS_RUNNER_TOKEN); + return resolved ? { Authorization: `Bearer ${resolved}` } : {}; +} + +export function runnerRequestHeaders(opts: RunnerRequestOptions): Record { + const resolved = clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN); + const headers: Record = resolved ? { Authorization: `Bearer ${resolved}` } : {}; + const instanceId = clean(opts.instanceId) || clean(process.env.PAGS_INSTANCE_ID); + if (instanceId) headers["X-PAGS-Instance-Id"] = instanceId; + return headers; +} + +export function apiPathSegment(value: string): string { + return encodeURIComponent(value); +} + +export function buildRunnerArgs(opts: RunnerStartOptions): string[] { + const args: string[] = []; + if (clean(opts.host)) args.push("--host", clean(opts.host) as string); + if (clean(opts.port)) args.push("--port", clean(opts.port) as string); + if (clean(opts.dataDir)) args.push("--data-dir", clean(opts.dataDir) as string); + if (clean(opts.token)) args.push("--token", clean(opts.token) as string); + if (clean(opts.instanceId)) args.push("--instance-id", clean(opts.instanceId) as string); + if (opts.headless) args.push("--headless"); + return args; +} + +export function buildRuntimeRegistrationBody(opts: RuntimeRegisterOptions, capabilities: string[] = []) { + return { + endpointUrl: clean(opts.endpointUrl) || opts.endpointUrl, + token: clean(opts.runnerToken) || clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN), + placement: opts.placement === "managed" ? "managed" : "local", + capabilities, + runnerVersion: clean(opts.runnerVersion) || "", + }; +} + +function clean(value?: string): string | undefined { + const trimmed = value?.trim(); + return trimmed || undefined; +} + +function findWorkspaceRoot(): string { + let dir = process.cwd(); + for (let i = 0; i < 8; i++) { + if (existsSync(resolve(dir, "pnpm-workspace.yaml"))) return dir; + const parent = resolve(dir, ".."); + if (parent === dir) break; + dir = parent; + } + return process.cwd(); +} + +function startRunnerForeground(opts: RunnerStartOptions): Promise { + const root = findWorkspaceRoot(); + const localPackage = resolve(root, "packages", "browser-runner", "src", "index.ts"); + const runnerArgs = buildRunnerArgs(opts); + const command = existsSync(localPackage) ? "pnpm" : "pags-browser-runner"; + const args = existsSync(localPackage) + ? ["--filter", "@proagentstore/browser-runner", "dev", "--", ...runnerArgs] + : runnerArgs; + + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd: root, + stdio: "inherit", + shell: process.platform === "win32", + }); + child.on("error", reject); + child.on("exit", (code) => { + if (code && code !== 0) reject(new Error(`runner exited with code ${code}`)); + else resolvePromise(); + }); + }); +} + +export async function requestRunner( + method: string, + path: string, + opts: RunnerRequestOptions, + body?: unknown, +): Promise { + const headers: Record = { + ...runnerRequestHeaders(opts), + }; + if (body !== undefined) headers["Content-Type"] = "application/json"; + const res = await fetch(`${runnerBaseUrl(opts.url)}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const { text, data } = await readResponse(res); + if (!res.ok) { + const message = responseErrorMessage(data, text, res.statusText); + throw new Error(`${res.status} ${message}`); + } + return data as T; +} + +export async function requestPags( + method: string, + path: string, + opts: PagsRequestOptions, + body?: unknown, +): Promise { + const headers: Record = { + ...pagsHeaders(opts.pagsToken), + }; + if (!headers.Authorization) { + throw new Error("PAGS token required. Set PAGS_TOKEN or pass --pags-token."); + } + if (body !== undefined) headers["Content-Type"] = "application/json"; + const res = await fetch(`${pagsApiBase(opts.apiBase)}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const { text, data } = await readResponse(res); + if (!res.ok) { + const message = responseErrorMessage(data, text, res.statusText); + throw new Error(`${res.status} ${message}`); + } + return data as T; +} + +async function readResponse(res: Response): Promise<{ text: string; data: Record }> { + const text = await res.text(); + if (!text) return { text, data: {} }; + try { + return { text, data: JSON.parse(text) as Record }; + } catch { + return { text, data: {} }; + } +} + +function responseErrorMessage( + data: Record, + text: string, + statusText: string, +): string { + return typeof data.error === "string" ? data.error : text || statusText; +} + +function collectCapability(value: string, previous: string[] = []): string[] { + return [...previous, value]; +} + +export function createRunnerCommand(): Command { + const command = new Command("runner").description( + "Manage a local ProAgentStore browser runner", + ); + + command + .command("start") + .description("Start the local browser runner in the foreground") + .option("--host ", "Host to bind", "127.0.0.1") + .option("--port ", "Port to bind", "49171") + .option("--data-dir ", "Runner data directory") + .option("--token ", "Require this bearer token") + .option("--instance-id ", "Bind runner requests to a PAGS instance id") + .option("--headless", "Run Playwright headless") + .action(async (opts: RunnerStartOptions) => { + await startRunnerForeground(opts); + }); + + command + .command("status") + .description("Check local runner health and capabilities") + .option("--url ", "Runner URL") + .option("--token ", "Runner bearer token") + .option("--instance-id ", "PAGS instance id header") + .action(async (opts: RunnerRequestOptions) => { + const health = await requestRunner("GET", "/health", opts); + const capabilities = await requestRunner("GET", "/capabilities", opts); + writeLine(JSON.stringify({ health, capabilities }, null, 2)); + }); + + command + .command("task") + .description("Create a runner task") + .requiredOption("--type ", "Task type, e.g. echo or browser.open") + .option("--url ", "Runner URL") + .option("--token ", "Runner bearer token") + .option("--instance-id ", "PAGS instance id header") + .option("--input ", "Task input JSON") + .option("--job-url ", "Shortcut input for browser.open") + .option("--approve", "Create task in needs_approval state") + .option("--approval-prompt ", "Approval prompt") + .action( + async ( + opts: RunnerRequestOptions & { + type: string; + input?: string; + jobUrl?: string; + approve?: boolean; + approvalPrompt?: string; + }, + ) => { + let input: Record = {}; + if (opts.input) { + try { + input = JSON.parse(opts.input) as Record; + } catch { + writeError("--input must be valid JSON"); + process.exit(1); + } + } + if (opts.jobUrl) input.url = opts.jobUrl; + const task = await requestRunner("POST", "/tasks", opts, { + type: opts.type, + input, + requiresApproval: Boolean(opts.approve), + approvalPrompt: opts.approvalPrompt, + }); + writeLine(JSON.stringify(task, null, 2)); + }, + ); + + command + .command("register ") + .description("Register a local or managed runner endpoint with a PAGS instance") + .requiredOption("--endpoint-url ", "Runner endpoint URL to store in PAGS") + .option("--api-base ", "PAGS API base URL") + .option("--pags-token ", "PAGS session token. Defaults to PAGS_TOKEN") + .option("--runner-token ", "Runner bearer token to store in PAGS") + .option("--url ", "Local runner URL to probe for capabilities") + .option("--token ", "Local runner bearer token for capability probe") + .option("--instance-id ", "PAGS instance id header for capability probe") + .option("--placement ", "Runtime placement: local or managed", "local") + .option("--runner-version ", "Runner version") + .option("--capability ", "Runtime capability; repeatable", collectCapability, []) + .option("--probe", "Read capabilities from the runner before registering") + .action(async (instanceId: string, opts: RuntimeRegisterOptions) => { + let capabilities = opts.capability || []; + if (opts.probe) { + const data = await requestRunner<{ capabilities?: unknown }>("GET", "/capabilities", { + url: opts.url || opts.endpointUrl, + token: opts.token || opts.runnerToken, + instanceId: opts.instanceId || instanceId, + }); + capabilities = Array.isArray(data.capabilities) + ? data.capabilities.filter((item): item is string => typeof item === "string") + : capabilities; + } + const body = buildRuntimeRegistrationBody(opts, capabilities); + const result = await requestPags("POST", `/v1/instances/${apiPathSegment(instanceId)}/runtime`, opts, body); + writeLine(JSON.stringify(result, null, 2)); + }); + + command + .command("runtime ") + .description("Read the PAGS runtime registration for an instance") + .option("--api-base ", "PAGS API base URL") + .option("--pags-token ", "PAGS session token. Defaults to PAGS_TOKEN") + .option("--probe", "Ask PAGS to probe /health and /capabilities on the runner") + .action(async (instanceId: string, opts: PagsRequestOptions & { probe?: boolean }) => { + const path = opts.probe + ? `/v1/instances/${apiPathSegment(instanceId)}/runtime/status` + : `/v1/instances/${apiPathSegment(instanceId)}/runtime`; + const result = await requestPags("GET", path, opts); + writeLine(JSON.stringify(result, null, 2)); + }); + + command + .command("unregister ") + .description("Remove the PAGS runtime registration for an instance") + .option("--api-base ", "PAGS API base URL") + .option("--pags-token ", "PAGS session token. Defaults to PAGS_TOKEN") + .action(async (instanceId: string, opts: PagsRequestOptions) => { + const result = await requestPags("DELETE", `/v1/instances/${apiPathSegment(instanceId)}/runtime`, opts); + writeLine(JSON.stringify(result, null, 2)); + }); + + command + .command("run ") + .description("Create a task through PAGS on an instance's registered runner") + .requiredOption("--type ", "Task type, e.g. echo or browser.open") + .option("--api-base ", "PAGS API base URL") + .option("--pags-token ", "PAGS session token. Defaults to PAGS_TOKEN") + .option("--input ", "Task input JSON") + .option("--job-url ", "Shortcut input for browser.open") + .option("--approve", "Create task in needs_approval state") + .option("--approval-prompt ", "Approval prompt") + .action( + async ( + instanceId: string, + opts: PagsRequestOptions & { + type: string; + input?: string; + jobUrl?: string; + approve?: boolean; + approvalPrompt?: string; + }, + ) => { + let input: Record = {}; + if (opts.input) { + try { + input = JSON.parse(opts.input) as Record; + } catch { + writeError("--input must be valid JSON"); + process.exit(1); + } + } + if (opts.jobUrl) input.url = opts.jobUrl; + const result = await requestPags("POST", `/v1/instances/${apiPathSegment(instanceId)}/tasks`, opts, { + type: opts.type, + input, + requiresApproval: Boolean(opts.approve), + approvalPrompt: opts.approvalPrompt, + }); + writeLine(JSON.stringify(result, null, 2)); + }, + ); + + command + .command("approve-task ") + .description("Approve a registered-runner task through PAGS") + .option("--api-base ", "PAGS API base URL") + .option("--pags-token ", "PAGS session token. Defaults to PAGS_TOKEN") + .action(async (instanceId: string, taskId: string, opts: PagsRequestOptions) => { + const result = await requestPags( + "POST", + `/v1/instances/${apiPathSegment(instanceId)}/tasks/${apiPathSegment(taskId)}/approve`, + opts, + ); + writeLine(JSON.stringify(result, null, 2)); + }); + + command + .command("cancel-task ") + .description("Cancel a registered-runner task through PAGS") + .option("--api-base ", "PAGS API base URL") + .option("--pags-token ", "PAGS session token. Defaults to PAGS_TOKEN") + .action(async (instanceId: string, taskId: string, opts: PagsRequestOptions) => { + const result = await requestPags( + "POST", + `/v1/instances/${apiPathSegment(instanceId)}/tasks/${apiPathSegment(taskId)}/cancel`, + opts, + ); + writeLine(JSON.stringify(result, null, 2)); + }); + + command + .command("task-events ") + .description("Read registered-runner task events through PAGS") + .option("--api-base ", "PAGS API base URL") + .option("--pags-token ", "PAGS session token. Defaults to PAGS_TOKEN") + .option("--limit ", "Number of events", "50") + .action(async (instanceId: string, opts: PagsRequestOptions & { limit: string }) => { + const result = await requestPags( + "GET", + `/v1/instances/${apiPathSegment(instanceId)}/task-events?limit=${Number(opts.limit) || 50}`, + opts, + ); + writeLine(JSON.stringify(result, null, 2)); + }); + + command + .command("approve ") + .description("Approve a task waiting on human approval") + .option("--url ", "Runner URL") + .option("--token ", "Runner bearer token") + .option("--instance-id ", "PAGS instance id header") + .action(async (taskId: string, opts: RunnerRequestOptions) => { + const task = await requestRunner("POST", `/tasks/${apiPathSegment(taskId)}/approve`, opts); + writeLine(JSON.stringify(task, null, 2)); + }); + + command + .command("cancel ") + .description("Cancel a local runner task") + .option("--url ", "Runner URL") + .option("--token ", "Runner bearer token") + .option("--instance-id ", "PAGS instance id header") + .action(async (taskId: string, opts: RunnerRequestOptions) => { + const task = await requestRunner("POST", `/tasks/${apiPathSegment(taskId)}/cancel`, opts); + writeLine(JSON.stringify(task, null, 2)); + }); + + command + .command("events") + .description("List recent runner events") + .option("--url ", "Runner URL") + .option("--token ", "Runner bearer token") + .option("--instance-id ", "PAGS instance id header") + .option("--limit ", "Number of events", "50") + .action(async (opts: RunnerRequestOptions & { limit: string }) => { + const events = await requestRunner("GET", `/events?limit=${Number(opts.limit) || 50}`, opts); + writeLine(JSON.stringify(events, null, 2)); + }); + + return command; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0b3a69fa..702d8add 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,6 +7,8 @@ import { Command } from "commander"; import { checkCommand } from "./commands/check.js"; import { initCommand } from "./commands/init.js"; import { publishCommand } from "./commands/publish.js"; +import { runnerCommand } from "./commands/runner.js"; +import { writeError } from "./output.js"; const program = new Command(); @@ -20,5 +22,11 @@ program program.addCommand(initCommand); program.addCommand(checkCommand); program.addCommand(publishCommand); +program.addCommand(runnerCommand); -program.parse(); +try { + await program.parseAsync(); +} catch (error) { + writeError(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2111c772..b7624ba9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,44 @@ importers: specifier: ^3.2.4 version: 3.2.6(@types/node@22.19.20)(tsx@4.22.4)(yaml@2.9.0) + agents/job-application-assistant: + dependencies: + hono: + specifier: ^4.7.0 + version: 4.12.23 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20250530.0 + version: 4.20260605.1 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.6(@types/node@22.19.20)(tsx@4.22.4)(yaml@2.9.0) + wrangler: + specifier: ^4.0.0 + version: 4.98.0(@cloudflare/workers-types@4.20260605.1) + + packages/browser-runner: + dependencies: + playwright: + specifier: ^1.60.0 + version: 1.60.0 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.20 + tsx: + specifier: ^4.21.0 + version: 4.22.4 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.6(@types/node@22.19.20)(tsx@4.22.4)(yaml@2.9.0) + packages/cli: dependencies: commander: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 22d4b5c2..1604bee8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ packages: - 'packages/*' - 'workers/*' + - 'agents/job-application-assistant' diff --git a/store/console/index.html b/store/console/index.html index ba05bb13..0b9645d6 100644 --- a/store/console/index.html +++ b/store/console/index.html @@ -62,6 +62,26 @@ .gh-icon{width:20px;height:20px;fill:currentColor} /* Agent grid */ + .agent-board-toolbar{display:flex;align-items:center;justify-content:space-between;gap:0.75rem;margin-bottom:0.75rem;flex-wrap:wrap} + .agent-board-actions{display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap} + .agent-board-summary{font-size:0.78rem;color:var(--muted)} + .view-toggle{display:inline-flex;border:1px solid var(--line);border-radius:0.5rem;overflow:hidden} + .view-toggle button{padding:0.38rem 0.7rem;font-size:0.78rem;font-weight:700;color:var(--muted);border-right:1px solid var(--line)} + .view-toggle button:last-child{border-right:none} + .view-toggle button.active{background:var(--panel-hover);color:var(--ink)} + .kanban-board{display:grid;grid-template-columns:repeat(4,minmax(190px,1fr));gap:0.75rem;align-items:start;overflow-x:auto;padding-bottom:0.35rem} + .kanban-column{border:1px solid var(--line);border-radius:0.5rem;background:rgba(20,20,20,0.55);min-height:220px;min-width:190px} + .kanban-header{display:flex;align-items:center;justify-content:space-between;gap:0.5rem;padding:0.75rem;border-bottom:1px solid var(--line)} + .kanban-title{display:flex;align-items:center;gap:0.45rem;font-size:0.82rem;font-weight:800} + .kanban-dot{width:0.55rem;height:0.55rem;border-radius:999px;background:var(--muted)} + .kanban-count{font-size:0.7rem;color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:0.08rem 0.42rem} + .kanban-items{display:flex;flex-direction:column;gap:0.5rem;padding:0.65rem} + .kanban-card{background:var(--paper);border:1px solid var(--line);border-radius:0.5rem;padding:0.75rem;cursor:pointer;transition:border-color 0.15s,background 0.15s} + .kanban-card:hover{border-color:var(--accent);background:var(--panel)} + .kanban-card h3{font-size:0.86rem;font-weight:700;margin-bottom:0.25rem;overflow-wrap:anywhere} + .kanban-card p{font-size:0.75rem;color:var(--muted);line-height:1.45;margin-bottom:0.55rem;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} + .kanban-card-meta{display:flex;gap:0.35rem;flex-wrap:wrap;font-size:0.7rem} + .kanban-empty{padding:0.75rem;color:var(--muted-soft);font-size:0.78rem;line-height:1.45} .agents-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr));gap:0.75rem} .agent-card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:1rem;cursor:pointer;transition:border-color 0.15s} .agent-card:hover{border-color:var(--accent)} @@ -70,7 +90,15 @@ .agent-meta{display:flex;gap:0.5rem;font-size:0.72rem} .tag{padding:0.1rem 0.4rem;border-radius:0.25rem;font-weight:500} .tag-draft{background:rgba(234,179,8,0.15);color:var(--yellow)} + .tag-unlisted{background:rgba(59,130,246,0.15);color:var(--blue)} .tag-published{background:rgba(34,197,94,0.15);color:var(--green)} + .tag-active{background:rgba(34,197,94,0.15);color:var(--green)} + .tag-inactive{background:rgba(163,163,163,0.14);color:var(--muted)} + .tag-error{background:rgba(239,68,68,0.15);color:var(--red)} + .tag-setup{background:rgba(234,179,8,0.15);color:var(--yellow)} + .tag-review{background:rgba(59,130,246,0.15);color:var(--blue)} + .tag-live{background:rgba(34,197,94,0.15);color:var(--green)} + .tag-attention{background:rgba(239,68,68,0.15);color:var(--red)} .tag-cat{background:var(--accent-soft);color:#a78bfa} /* Create agent form */ @@ -244,7 +272,28 @@

Create Agent

-
+
+
Loading agent status...
+
+ +
+ + +
+
+
+ +
+
Loading agents...
@@ -743,6 +792,46 @@

SDK Quick Start

let token = null; let user = null; let currentAgent = null; + let agentsView = 'board'; + const DEFAULT_AGENT_STATUS_BOARD_CONFIG = { + summary: 'setup, review, live, and attention', + columns: [ + { + id: 'setup', + title: 'Setup', + color: 'var(--yellow)', + empty: 'Draft agents appear here until they are ready to share.', + statuses: ['inactive'], + visibilities: ['draft'], + excludeStatuses: ['active', 'error'], + }, + { + id: 'review', + title: 'Review', + color: 'var(--blue)', + empty: 'Unlisted agents appear here for private testing.', + visibilities: ['unlisted'], + excludeStatuses: ['active', 'error'], + }, + { + id: 'live', + title: 'Live', + color: 'var(--green)', + empty: 'Published or active agents appear here.', + statuses: ['active'], + visibilities: ['published'], + excludeStatuses: ['error'], + }, + { + id: 'attention', + title: 'Attention', + color: 'var(--red)', + empty: 'Agents with errors appear here.', + statuses: ['error'], + }, + ], + }; + let agentStatusBoardConfig = structuredClone(DEFAULT_AGENT_STATUS_BOARD_CONFIG); // ── Auth ──────────────────────────────────────────────────── @@ -798,7 +887,11 @@

SDK Quick Start

if (!token) return false; try { const data = await api('/v1/auth/me'); - if (data.id) { user = data; return true; } + if (data.id) { + user = data; + setBoardConfig(user.boardConfig || DEFAULT_AGENT_STATUS_BOARD_CONFIG); + return true; + } } catch { setToken(null); } return false; } @@ -826,27 +919,205 @@

SDK Quick Start

document.getElementById('agents-loading').classList.remove('hidden'); try { const data = await api('/v1/agents/my/agents'); + const agents = data.agents || []; + renderAgents(agents); + } catch (e) { console.error(e); } + document.getElementById('agents-loading').classList.add('hidden'); + } + + function renderAgents(agents) { + const empty = document.getElementById('agents-empty'); + const summary = document.getElementById('agents-board-summary'); + empty.classList.toggle('hidden', agents.length > 0); + summary.textContent = agents.length + ? `${agents.length} agent${agents.length === 1 ? '' : 's'} across ${agentStatusBoardConfig.summary || 'configured columns'}` + : 'No agent status yet'; + renderKanbanBoard({ + boardId: 'agents-board', + items: agents, + columns: agentStatusBoardConfig.columns, + renderCard: agentStatusCard, + }); + renderAgentList(agents); + applyAgentsView(); + } + + function agentLifecycle(a) { + return firstMatchingColumn(a)?.id || 'setup'; + } + + function agentLifecycleLabel(a) { + return firstMatchingColumn(a)?.title || 'Unmatched'; + } + + function renderKanbanBoard({ boardId, items, columns, renderCard }) { + const board = document.getElementById(boardId); + board.innerHTML = ''; + for (const col of columns) { + const columnItems = items.filter(item => firstMatchingColumn(item, columns)?.id === col.id); + const column = document.createElement('section'); + column.className = 'kanban-column'; + column.setAttribute('aria-label', `${col.title} column`); + column.innerHTML = ` +
+
${esc(col.title)}
+ ${columnItems.length} +
+
`; + const list = column.querySelector('.kanban-items'); + if (!columnItems.length) { + list.innerHTML = `
${esc(col.empty)}
`; + } else { + for (const item of columnItems) list.appendChild(renderCard(item)); + } + board.appendChild(column); + } + } + + function columnMatchesAgent(column, agent) { + const status = agent.status || 'inactive'; + const visibility = agent.visibility || 'draft'; + const excludeStatuses = Array.isArray(column.excludeStatuses) ? column.excludeStatuses : []; + const excludeVisibilities = Array.isArray(column.excludeVisibilities) ? column.excludeVisibilities : []; + if (excludeStatuses.includes(status) || excludeVisibilities.includes(visibility)) return false; + if (column.catchAll) return true; + const statuses = Array.isArray(column.statuses) ? column.statuses : []; + const visibilities = Array.isArray(column.visibilities) ? column.visibilities : []; + return statuses.includes(status) || visibilities.includes(visibility); + } + + function safeBoardColor(value) { + const color = String(value || '').trim().slice(0, 40); + if (/^(#[0-9a-f]{3,8}|[a-z]+|rgba?\([0-9, .%]+\)|hsla?\([0-9, .%]+\)|var\(--[a-z0-9-]+\))$/i.test(color)) { + return color; + } + return 'var(--accent)'; + } + + function firstMatchingColumn(agent, columns = agentStatusBoardConfig.columns) { + return columns.find(col => columnMatchesAgent(col, agent)) || columns[0]; + } + + function normalizeBoardConfig(config) { + const source = config && Array.isArray(config.columns) ? config : DEFAULT_AGENT_STATUS_BOARD_CONFIG; + const columns = source.columns + .filter(col => col?.id && col.title) + .slice(0, 8) + .map(col => ({ + id: String(col.id).replace(/[^a-z0-9_-]/gi, '-').toLowerCase(), + title: String(col.title).slice(0, 40), + color: safeBoardColor(col.color || 'var(--accent)'), + empty: String(col.empty || 'No agents in this column.').slice(0, 160), + statuses: Array.isArray(col.statuses) ? col.statuses.map(String).slice(0, 10) : [], + visibilities: Array.isArray(col.visibilities) ? col.visibilities.map(String).slice(0, 10) : [], + excludeStatuses: Array.isArray(col.excludeStatuses) ? col.excludeStatuses.map(String).slice(0, 10) : [], + excludeVisibilities: Array.isArray(col.excludeVisibilities) ? col.excludeVisibilities.map(String).slice(0, 10) : [], + catchAll: Boolean(col.catchAll), + })); + if (!columns.length) throw new Error('Board config needs at least one column.'); + return { + summary: String(source.summary || columns.map(c => c.title.toLowerCase()).join(', ')).slice(0, 120), + columns, + }; + } + + function setBoardConfig(config) { + agentStatusBoardConfig = normalizeBoardConfig(config); + document.getElementById('board-config-json').value = JSON.stringify(agentStatusBoardConfig, null, 2); + } + + function toggleBoardConfig() { + const form = document.getElementById('board-config-form'); + form.classList.toggle('hidden'); + if (!form.classList.contains('hidden')) { + document.getElementById('board-config-json').value = JSON.stringify(agentStatusBoardConfig, null, 2); + document.getElementById('board-config-error').classList.add('hidden'); + } + } + + async function saveBoardConfig() { + const error = document.getElementById('board-config-error'); + try { + const parsed = JSON.parse(document.getElementById('board-config-json').value); + const config = normalizeBoardConfig(parsed); + await api('/v1/auth/me', { + method: 'PUT', + body: JSON.stringify({ board_config: config }), + }); + user.boardConfig = config; + setBoardConfig(config); + document.getElementById('board-config-form').classList.add('hidden'); + loadAgents(); + } catch (e) { + error.textContent = e.message; + error.classList.remove('hidden'); + } + } + + async function resetBoardConfig() { + setBoardConfig(DEFAULT_AGENT_STATUS_BOARD_CONFIG); + await api('/v1/auth/me', { + method: 'PUT', + body: JSON.stringify({ board_config: DEFAULT_AGENT_STATUS_BOARD_CONFIG }), + }); + if (user) user.boardConfig = DEFAULT_AGENT_STATUS_BOARD_CONFIG; + document.getElementById('board-config-form').classList.add('hidden'); + loadAgents(); + } + + function agentStatusCard(a) { + const card = document.createElement('article'); + card.className = 'kanban-card'; + card.tabIndex = 0; + card.setAttribute('role', 'button'); + card.setAttribute('aria-label', `Open ${a.name}`); + card.innerHTML = ` +

${esc(a.name)}

+

${esc(a.description || 'No description')}

+
Updated ${esc(formatTime(a.updated_at))}
+
+ ${esc(a.visibility || 'draft')} + ${esc(a.status || 'inactive')} + ${esc(a.category || 'general')} +
`; + card.addEventListener('click', () => openAgent(a.id)); + card.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + openAgent(a.id); + } + }); + return card; + } + + function renderAgentList(agents) { const list = document.getElementById('agents-list'); - const empty = document.getElementById('agents-empty'); list.innerHTML = ''; - if (!data.agents.length) { empty.classList.remove('hidden'); } - else { - empty.classList.add('hidden'); - for (const a of data.agents) { + for (const a of agents) { const card = document.createElement('div'); card.className = 'agent-card'; card.innerHTML = `

${esc(a.name)}

${esc(a.description || 'No description')}

${esc(a.visibility)} + ${esc(agentLifecycleLabel(a))} ${esc(a.category)}
`; card.addEventListener('click', () => openAgent(a.id)); list.appendChild(card); - } } - } catch (e) { console.error(e); } - document.getElementById('agents-loading').classList.add('hidden'); + } + + function switchAgentsView(view) { + agentsView = view === 'list' ? 'list' : 'board'; + applyAgentsView(); + } + + function applyAgentsView() { + document.getElementById('agents-board').classList.toggle('hidden', agentsView !== 'board'); + document.getElementById('agents-list').classList.toggle('hidden', agentsView !== 'list'); + document.getElementById('agents-view-board').classList.toggle('active', agentsView === 'board'); + document.getElementById('agents-view-list').classList.toggle('active', agentsView === 'list'); } function showDashboard() { diff --git a/store/docs/browser-runtime/index.html b/store/docs/browser-runtime/index.html new file mode 100644 index 00000000..ad5d0a46 --- /dev/null +++ b/store/docs/browser-runtime/index.html @@ -0,0 +1,259 @@ + + + + + + Browser Runtime Architecture — ProAgentStore Docs + + + + + + + + + + +
+ +
+ +
+ + +
+
Architecture docs
+

Browser-Capable Agent Runtime

+

A proposed runtime model for agents that need to drive a real browser: ProAgentStore-managed browser runners by default, with local Playwright through Cloudflare Tunnel only when a user does not want to pay for a managed VM.

+
+ Status: proposed + Decision owner: not finalized + PAS precedent: our loop + Reference agent: job applications +
+ +
+

Decision Status

+
+

This is not a ratified product decision. The current recommendation follows the PAS Agent Teams pattern: our system runs the agent loop, with billing/key choices layered on top. It still needs product and engineering approval for PAGS browser agents.

+

The recommendation is: browser-capable agents run their active brain in ProAgentStore-managed browser runner infrastructure by default. Local machine Playwright is the fallback for users who choose not to spend on a managed VM.

+
+
+ +
+

System Diagram

+
+
+
+

PAGS Control Plane

+

Marketplace, MCP, subscriptions, runtime assignment, task summaries, auth, billing, audit events.

+
+
->
+
+

Managed Browser Runner

+

Default agent brain, LLM calls, task state, Playwright controller, VM storage.

+
+
->
+
+

Real Browser

+

Logged-in job boards, DOM, screenshots, file uploads, downloads, manual handoff.

+
+
+
+

PAGS starts and observes work. The managed runner does the active thinking and browser control. Local runner uses the same protocol only when selected as a no-VM option.

+
+ +
+

Runtime Modes

+
+
+ Hosted Worker +

Good for text-only/server-only agents. No Playwright. No real browser session.

+
+
+ Managed Browser Runner +

Default paid path. ProAgentStore-managed VM/container with Playwright and persistent browser state.

+
+
+ Local Browser Runner +

No-VM option. Node plus Playwright on the user's machine, exposed through Cloudflare Tunnel.

+
+
+
+ +
+

Brain Placement

+ + + + + + + + + + + + + + + + + + + + + + + + +
OptionUpsideProblemRecommendation
Brain in PAGS WorkerCentralized orchestration.Browser screenshots, DOM, cookies, and forms must travel through PAGS; high-latency observe/act loop.Use for text-only agents, not browser agents.
Brain in managed VM runnerAlways-on, managed, supportable, billable, same browser protocol.Costs more and needs stronger isolation/retention policy.MVP recommendation.
Brain in local runnerUser avoids VM spend and can keep sessions/files local.User machine must be online and configured; harder to support.Fallback option, not default.
+
+ +
+

Task Lifecycle

+
+
+
+

PAGS / MCP

+
Create subscribed instance
+
Assign managed runner or register local tunnel
+
Start task with job URL
+
Receive events and status
+
Approve final submit
+
+
+

Browser Runner

+
Open browser context
+
Observe page and detect job board
+
Plan and fill application
+
Pause for login/captcha/missing answers
+
Submit only after approval
+
+
+
+
+ +
+

Protocol Sketch

+

Agents that need browser automation should declare it in their manifest.

+
{ + "runtime": { + "kind": "browser-runner", + "brain": "managed-runner-default", + "browser": { + "engine": "playwright", + "persistentProfile": true, + "manualHandoff": true, + "fileUploads": true + }, + "deployment": { + "managedVm": true, + "localTunnel": true, + "hostedWorker": false + } + } +}
+

Runtime endpoints should be signed and instance-scoped, not open public tunnel URLs.

+
GET /health +GET /capabilities +POST /tasks +GET /tasks/:id +POST /tasks/:id/approve +POST /tasks/:id/cancel +GET /events
+
+ +
+

Implementation Plan

+
    +
  • Define browser-runner manifest and runtime registration schema.
  • +
  • Build the managed Node runner with Playwright persistent profiles.
  • +
  • Add VM/container provisioning and runner assignment in PAGS.
  • +
  • Add MCP tools for registering runtimes, starting tasks, approvals, and event reads.
  • +
  • Move the job application agent to the browser-runner protocol.
  • +
  • Add local Cloudflare Tunnel mode as the no-VM fallback.
  • +
+
+
+
+ + + + diff --git a/vitest.config.ts b/vitest.config.ts index 12239f19..e5625656 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,10 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["packages/*/src/**/*.test.ts", "workers/*/src/**/*.test.ts"], + include: [ + "packages/*/src/**/*.test.ts", + "workers/*/src/**/*.test.ts", + "agents/job-application-assistant/src/**/*.test.ts", + ], }, }); diff --git a/workers/api/migrations/0010_board_config.sql b/workers/api/migrations/0010_board_config.sql new file mode 100644 index 00000000..34bbedbd --- /dev/null +++ b/workers/api/migrations/0010_board_config.sql @@ -0,0 +1,2 @@ +-- Creator console preferences +ALTER TABLE users ADD COLUMN board_config TEXT NOT NULL DEFAULT ''; diff --git a/workers/api/migrations/0011_instance_runtimes.sql b/workers/api/migrations/0011_instance_runtimes.sql new file mode 100644 index 00000000..21b1521a --- /dev/null +++ b/workers/api/migrations/0011_instance_runtimes.sql @@ -0,0 +1,23 @@ +-- Local/managed runtime registrations for browser-capable instances. +-- PAGS owns the brain and task orchestration; the registered runtime is a +-- capability/tool executor for one user's private instance. + +CREATE TABLE instance_runtimes ( + instance_id TEXT PRIMARY KEY REFERENCES agent_instances(id), + user_id TEXT NOT NULL REFERENCES users(id), + placement TEXT NOT NULL DEFAULT 'local', -- local, managed + endpoint_url TEXT NOT NULL, + token_ciphertext BLOB, + token_dek_wrapped BLOB, + token_iv BLOB, + token_plaintext TEXT, -- local/dev fallback when KEK is unavailable + capabilities TEXT NOT NULL DEFAULT '[]', -- JSON array + runner_version TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'registered', -- registered, online, offline + last_seen_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_instance_runtimes_user ON instance_runtimes(user_id); +CREATE INDEX idx_instance_runtimes_status ON instance_runtimes(status, updated_at); diff --git a/workers/api/src/migration.test.ts b/workers/api/src/migration.test.ts index 57b4d581..7e9c457b 100644 --- a/workers/api/src/migration.test.ts +++ b/workers/api/src/migration.test.ts @@ -8,6 +8,14 @@ const migration = readFileSync( join(__dirname, "../migrations/0001_init.sql"), "utf-8", ); +const boardConfigMigration = readFileSync( + join(__dirname, "../migrations/0010_board_config.sql"), + "utf-8", +); +const runtimeMigration = readFileSync( + join(__dirname, "../migrations/0011_instance_runtimes.sql"), + "utf-8", +); describe("D1 migration 0001_init", () => { it("creates users table", () => { @@ -66,3 +74,25 @@ describe("D1 migration 0001_init", () => { expect(migration).not.toContain("BOOLEAN"); // SQLite has no BOOLEAN, use INTEGER }); }); + +describe("D1 migration 0010_board_config", () => { + it("adds persisted creator board configuration", () => { + expect(boardConfigMigration).toContain("ALTER TABLE users ADD COLUMN board_config TEXT"); + expect(boardConfigMigration).toContain("DEFAULT ''"); + }); +}); + +describe("D1 migration 0011_instance_runtimes", () => { + it("creates instance runtime registrations", () => { + expect(runtimeMigration).toContain("CREATE TABLE instance_runtimes"); + expect(runtimeMigration).toContain("instance_id TEXT PRIMARY KEY REFERENCES agent_instances(id)"); + expect(runtimeMigration).toContain("endpoint_url TEXT NOT NULL"); + expect(runtimeMigration).toContain("token_ciphertext BLOB"); + expect(runtimeMigration).toContain("capabilities TEXT NOT NULL DEFAULT '[]'"); + }); + + it("adds lookup indexes", () => { + expect(runtimeMigration).toContain("CREATE INDEX idx_instance_runtimes_user"); + expect(runtimeMigration).toContain("CREATE INDEX idx_instance_runtimes_status"); + }); +}); diff --git a/workers/api/src/routes/auth.test.ts b/workers/api/src/routes/auth.test.ts index 18837525..9695b65e 100644 --- a/workers/api/src/routes/auth.test.ts +++ b/workers/api/src/routes/auth.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { normalizeBoardConfigInput } from "./auth.js"; describe("auth route response shapes", () => { it("OAuth callback returns expected shape", () => { @@ -26,9 +27,14 @@ describe("auth route response shapes", () => { avatar: "https://avatars.githubusercontent.com/u/12345", roles: ["user", "creator"], hasSubscription: true, + boardConfig: { + summary: "setup and live", + columns: [{ id: "setup", title: "Setup" }], + }, }; expect(response.roles).toHaveLength(2); expect(response.hasSubscription).toBe(true); + expect(response.boardConfig.columns[0].id).toBe("setup"); }); it("role parsing from JSON string", () => { @@ -43,3 +49,48 @@ describe("auth route response shapes", () => { expect(roles).toEqual(["user"]); }); }); + +describe("board config normalization", () => { + it("normalizes object configs for persistence", () => { + const config = normalizeBoardConfigInput({ + summary: "custom board", + columns: [ + { + id: "Needs Review!", + title: "Needs Review", + color: 'red" onmouseover="alert(1)', + statuses: ["inactive"], + visibilities: ["draft"], + excludeStatuses: ["error"], + }, + ], + }); + + expect(config.summary).toBe("custom board"); + expect(config.columns[0]).toMatchObject({ + id: "needs-review-", + title: "Needs Review", + color: "var(--accent)", + statuses: ["inactive"], + visibilities: ["draft"], + excludeStatuses: ["error"], + excludeVisibilities: [], + catchAll: false, + }); + }); + + it("accepts JSON string configs", () => { + const config = normalizeBoardConfigInput(JSON.stringify({ + columns: [{ id: "live", title: "Live", catchAll: true }], + })); + + expect(config.columns[0].id).toBe("live"); + expect(config.columns[0].catchAll).toBe(true); + }); + + it("rejects invalid configs", () => { + expect(() => normalizeBoardConfigInput("{")).toThrow("valid JSON"); + expect(() => normalizeBoardConfigInput({ columns: [] })).toThrow("at least one"); + expect(() => normalizeBoardConfigInput({ columns: [{ id: "x" }] })).toThrow("id and title"); + }); +}); diff --git a/workers/api/src/routes/auth.ts b/workers/api/src/routes/auth.ts index 553fc4df..fbca8614 100644 --- a/workers/api/src/routes/auth.ts +++ b/workers/api/src/routes/auth.ts @@ -6,6 +6,96 @@ export const authRoutes = new Hono<{ Bindings: Env }>(); const FAS_API = "https://api.freeappstore.online"; +function parseJsonOrNull(value: string | null | undefined): unknown { + if (!value) return null; + try { + return JSON.parse(value); + } catch { + return null; + } +} + +interface BoardColumnConfig { + id: string; + title: string; + color: string; + empty: string; + statuses: string[]; + visibilities: string[]; + excludeStatuses: string[]; + excludeVisibilities: string[]; + catchAll: boolean; +} + +interface BoardConfig { + summary: string; + columns: BoardColumnConfig[]; +} + +function strings(value: unknown, maxItems = 10): string[] { + return Array.isArray(value) + ? value.map((item) => String(item).slice(0, 40)).slice(0, maxItems) + : []; +} + +function safeBoardColor(value: unknown): string { + const color = String(value || "").trim().slice(0, 40); + if ( + /^(#[0-9a-f]{3,8}|[a-z]+|rgba?\([0-9, .%]+\)|hsla?\([0-9, .%]+\)|var\(--[a-z0-9-]+\))$/i.test( + color, + ) + ) { + return color; + } + return "var(--accent)"; +} + +export function normalizeBoardConfigInput(input: unknown): BoardConfig { + let source = input; + if (typeof input === "string") { + try { + source = JSON.parse(input); + } catch { + throw new Error("board_config must be valid JSON"); + } + } + if (!source || typeof source !== "object") { + throw new Error("board_config must be an object"); + } + const raw = source as { summary?: unknown; columns?: unknown }; + if (!Array.isArray(raw.columns) || raw.columns.length === 0) { + throw new Error("board_config.columns must contain at least one column"); + } + const columns = raw.columns + .slice(0, 8) + .map((column): BoardColumnConfig => { + if (!column || typeof column !== "object") { + throw new Error("board_config columns must be objects"); + } + const col = column as Record; + if (!col.id || !col.title) { + throw new Error("board_config columns require id and title"); + } + return { + id: String(col.id).replace(/[^a-z0-9_-]/gi, "-").toLowerCase().slice(0, 40), + title: String(col.title).slice(0, 40), + color: safeBoardColor(col.color || "var(--accent)"), + empty: String(col.empty || "No agents in this column.").slice(0, 160), + statuses: strings(col.statuses), + visibilities: strings(col.visibilities), + excludeStatuses: strings(col.excludeStatuses), + excludeVisibilities: strings(col.excludeVisibilities), + catchAll: Boolean(col.catchAll), + }; + }); + return { + summary: String( + raw.summary || columns.map((column) => column.title.toLowerCase()).join(", "), + ).slice(0, 120), + columns, + }; +} + /** Auth config — tells the console how to start the OAuth flow. */ authRoutes.get("/config", async (c) => { return c.json({ @@ -175,19 +265,30 @@ authRoutes.put("/me", async (c) => { website?: string; twitter?: string; slack_webhook?: string; + board_config?: unknown; }>(); + let boardConfig: string | undefined; + if (body.board_config !== undefined) { + try { + boardConfig = JSON.stringify(normalizeBoardConfigInput(body.board_config)); + } catch (error) { + return c.json({ error: error instanceof Error ? error.message : "Invalid board_config" }, 400); + } + } const allowed = [ ["display_name", "display_name"], ["bio", "bio"], ["website", "website"], ["twitter", "twitter"], ["slack_webhook", "slack_webhook"], + ["board_config", "board_config"], ] as const; const sets: string[] = ["updated_at = datetime('now')"]; const params: unknown[] = []; for (const [key, column] of allowed) { - if (body[key] !== undefined) { - params.push(body[key]); + const value = key === "board_config" ? boardConfig : body[key]; + if (value !== undefined) { + params.push(value); sets.push(`${column} = ?${params.length + 1}`); } } @@ -213,7 +314,7 @@ authRoutes.get("/me", async (c) => { if (!session) return c.json({ error: "Invalid or expired token" }, 401); const row = await c.env.DB.prepare( - "SELECT id, github_login, github_name, avatar_url, roles, stripe_customer_id, display_name, bio, website, twitter, slack_webhook FROM users WHERE id = ?1", + "SELECT id, github_login, github_name, avatar_url, roles, stripe_customer_id, display_name, bio, website, twitter, slack_webhook, board_config FROM users WHERE id = ?1", ) .bind(session.uid) .first>(); @@ -230,5 +331,6 @@ authRoutes.get("/me", async (c) => { website: row.website || "", twitter: row.twitter || "", slackWebhook: row.slack_webhook ? "configured" : "", + boardConfig: parseJsonOrNull(row.board_config), }); }); diff --git a/workers/api/src/routes/instances.test.ts b/workers/api/src/routes/instances.test.ts index dcf17f41..6ae4339d 100644 --- a/workers/api/src/routes/instances.test.ts +++ b/workers/api/src/routes/instances.test.ts @@ -1,4 +1,9 @@ import { describe, expect, it } from "vitest"; +import { HttpError } from "../lib/auth.js"; +import { + normalizeRunnerTaskBody, + validateRuntimeEndpointUrl, +} from "./instances.js"; describe("instance ID generation", () => { it("generates a valid UUID v4", () => { @@ -119,3 +124,77 @@ describe("instance ownership check", () => { expect(found).toBeUndefined(); }); }); + +describe("runtime endpoint validation", () => { + it("accepts https tunnel endpoints and strips path trailing slash", () => { + expect(validateRuntimeEndpointUrl("https://runner.example.com/")).toBe( + "https://runner.example.com", + ); + }); + + it("accepts localhost http for development", () => { + expect(validateRuntimeEndpointUrl("http://127.0.0.1:49171")).toBe( + "http://127.0.0.1:49171", + ); + expect(validateRuntimeEndpointUrl("http://localhost:49171/")).toBe( + "http://localhost:49171", + ); + expect(validateRuntimeEndpointUrl("http://[::1]:49171/")).toBe( + "http://[::1]:49171", + ); + }); + + it("rejects non-https non-local endpoints", () => { + expect(() => validateRuntimeEndpointUrl("http://runner.example.com")).toThrow( + HttpError, + ); + }); + + it("rejects invalid URLs", () => { + expect(() => validateRuntimeEndpointUrl("not a url")).toThrow(HttpError); + }); +}); + +describe("runtime task protocol shape", () => { + it("creates PAGS-brain runner task request shape", () => { + const request = { + type: "browser.open", + input: { url: "https://example.com/jobs/1" }, + requiresApproval: true, + approvalPrompt: "Open job page locally", + }; + expect(request.type).toBe("browser.open"); + expect(request.requiresApproval).toBe(true); + expect(request.input.url).toContain("https://"); + }); + + it("runtime response never includes token material", () => { + const runtime = { + instanceId: "inst-1", + endpointUrl: "https://runner.example.com", + hasToken: true, + }; + expect(runtime).not.toHaveProperty("token"); + expect(runtime).not.toHaveProperty("tokenPlaintext"); + }); + + it("normalizes browser.open tasks as approval-required at the PAGS boundary", () => { + expect( + normalizeRunnerTaskBody({ + type: " browser.open ", + input: { url: "https://example.com/jobs/1" }, + requiresApproval: false, + }), + ).toMatchObject({ + type: "browser.open", + input: { url: "https://example.com/jobs/1" }, + requiresApproval: true, + approvalPrompt: "Approve task browser.open", + }); + }); + + it("rejects invalid runner task bodies", () => { + expect(() => normalizeRunnerTaskBody({ input: {} })).toThrow(HttpError); + expect(() => normalizeRunnerTaskBody({ type: "" })).toThrow(HttpError); + }); +}); diff --git a/workers/api/src/routes/instances.ts b/workers/api/src/routes/instances.ts index 65559375..51df83f5 100644 --- a/workers/api/src/routes/instances.ts +++ b/workers/api/src/routes/instances.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import type { ContentfulStatusCode } from "hono/utils/http-status"; import { HttpError, requireUser } from "../lib/auth.js"; +import { decryptKey, encryptKey } from "../lib/crypto.js"; import { createNotification } from "./notifications.js"; import type { Env } from "../types.js"; @@ -16,6 +17,242 @@ interface InstanceRow { updated_at: string; } +interface RuntimeRow { + instance_id: string; + user_id: string; + placement: string; + endpoint_url: string; + token_ciphertext: ArrayBuffer | Uint8Array | null; + token_dek_wrapped: ArrayBuffer | Uint8Array | null; + token_iv: ArrayBuffer | Uint8Array | null; + token_plaintext: string | null; + capabilities: string; + runner_version: string; + status: string; + last_seen_at: string | null; + created_at: string; + updated_at: string; +} + +interface RuntimeRegistrationBody { + endpointUrl: string; + token?: string; + placement?: "local" | "managed"; + capabilities?: unknown[]; + runnerVersion?: string; +} + +interface RunnerTaskBody { + type: string; + input?: Record; + requiresApproval?: boolean; + approvalPrompt?: string; +} + +const APPROVAL_REQUIRED_RUNNER_TASKS = new Set(["browser.open"]); + +export function validateRuntimeEndpointUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new HttpError(400, "endpointUrl must be a valid URL"); + } + + const isLocalhost = + url.hostname === "localhost" || + url.hostname === "127.0.0.1" || + url.hostname === "[::1]" || + url.hostname === "::1"; + if (url.protocol !== "https:" && !(isLocalhost && url.protocol === "http:")) { + throw new HttpError(400, "endpointUrl must be https, except localhost for development"); + } + url.pathname = url.pathname.replace(/\/+$/, ""); + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/$/, ""); +} + +function safeCapabilities(value: unknown): unknown[] { + return Array.isArray(value) + ? value.filter((item) => typeof item === "string").slice(0, 50) + : []; +} + +function runtimeResponse(row: RuntimeRow) { + return { + instanceId: row.instance_id, + placement: row.placement, + endpointUrl: row.endpoint_url, + capabilities: safeParseArray(row.capabilities), + runnerVersion: row.runner_version, + status: row.status, + lastSeenAt: row.last_seen_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + hasToken: Boolean(row.token_plaintext || row.token_ciphertext), + }; +} + +function safeParseArray(value: string): unknown[] { + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function normalizeRunnerTaskBody(value: unknown): RunnerTaskBody { + if (!isRecord(value) || typeof value.type !== "string" || !value.type.trim()) { + throw new HttpError(400, "task type required"); + } + const type = value.type.trim().slice(0, 120); + const requiresApproval = + value.requiresApproval === true || APPROVAL_REQUIRED_RUNNER_TASKS.has(type); + return { + type, + input: isRecord(value.input) ? value.input : {}, + requiresApproval, + approvalPrompt: typeof value.approvalPrompt === "string" + ? value.approvalPrompt.slice(0, 500) + : requiresApproval + ? `Approve task ${type}` + : undefined, + }; +} + +async function requireOwnedInstance( + env: Env, + instanceId: string, + userId: string, +): Promise { + const instance = await env.DB.prepare( + "SELECT id, agent_id, user_id, status, config, created_at, updated_at FROM agent_instances WHERE id = ?1 AND user_id = ?2", + ) + .bind(instanceId, userId) + .first(); + if (!instance) throw new HttpError(404, "Instance not found"); + return instance; +} + +async function getRuntime( + env: Env, + instanceId: string, + userId: string, +): Promise { + return env.DB.prepare( + "SELECT * FROM instance_runtimes WHERE instance_id = ?1 AND user_id = ?2", + ) + .bind(instanceId, userId) + .first(); +} + +async function requireRuntime( + env: Env, + instanceId: string, + userId: string, +): Promise { + const runtime = await getRuntime(env, instanceId, userId); + if (!runtime) throw new HttpError(404, "Runtime not registered"); + return runtime; +} + +async function encodeRuntimeToken(env: Env, token: string | undefined): Promise<{ + ciphertext: Uint8Array | null; + dekWrapped: Uint8Array | null; + iv: Uint8Array | null; + plaintext: string | null; +}> { + if (!token) { + return { ciphertext: null, dekWrapped: null, iv: null, plaintext: null }; + } + if (!env.KEY_ENCRYPTION_KEY) { + return { ciphertext: null, dekWrapped: null, iv: null, plaintext: token }; + } + const encrypted = await encryptKey(token, env.KEY_ENCRYPTION_KEY); + return { + ciphertext: encrypted.ciphertext, + dekWrapped: encrypted.dekWrapped, + iv: encrypted.iv, + plaintext: null, + }; +} + +async function decodeRuntimeToken(env: Env, row: RuntimeRow): Promise { + if (row.token_plaintext) return row.token_plaintext; + if ( + !row.token_ciphertext || + !row.token_dek_wrapped || + !row.token_iv || + !env.KEY_ENCRYPTION_KEY + ) { + return null; + } + return decryptKey( + new Uint8Array(row.token_ciphertext), + new Uint8Array(row.token_dek_wrapped), + new Uint8Array(row.token_iv), + env.KEY_ENCRYPTION_KEY, + ); +} + +async function callRuntime( + env: Env, + row: RuntimeRow, + path: string, + init: RequestInit = {}, +): Promise { + const token = await decodeRuntimeToken(env, row); + const url = new URL(path, `${row.endpoint_url}/`); + const headers = new Headers(init.headers); + if (token) headers.set("Authorization", `Bearer ${token}`); + headers.set("X-PAGS-Instance-Id", row.instance_id); + headers.set("X-PAGS-Runtime-Placement", row.placement); + if (init.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + return fetch(url.toString(), { + ...init, + headers, + }); +} + +async function runtimeJson(res: Response): Promise { + const text = await res.text(); + if (!text) return {}; + try { + return JSON.parse(text) as unknown; + } catch { + return { + error: text || res.statusText || "Runtime returned a non-JSON response", + }; + } +} + +function runtimeStatus(res: Response, okStatus: number): ContentfulStatusCode { + return (res.ok ? okStatus : Math.max(400, Math.min(599, res.status))) as ContentfulStatusCode; +} + +async function updateRuntimeStatus( + env: Env, + instanceId: string, + userId: string, + status: string, +): Promise { + await env.DB.prepare( + `UPDATE instance_runtimes + SET status = ?1, last_seen_at = datetime('now'), updated_at = datetime('now') + WHERE instance_id = ?2 AND user_id = ?3`, + ) + .bind(status, instanceId, userId) + .run(); +} + /** Subscribe to an agent — creates a personal instance with its own DO. */ instanceRoutes.post("/:agentId/subscribe", async (c) => { const session = await requireUser(c); @@ -143,6 +380,188 @@ instanceRoutes.get("/my/instances", async (c) => { return c.json({ instances: results }); }); +/** Register or update the local/managed runtime for my instance. */ +instanceRoutes.post("/:instanceId/runtime", async (c) => { + const session = await requireUser(c); + const instanceId = c.req.param("instanceId"); + await requireOwnedInstance(c.env, instanceId, session.uid); + + const body = await c.req.json(); + const endpointUrl = validateRuntimeEndpointUrl(body.endpointUrl); + const tokenParts = await encodeRuntimeToken(c.env, body.token); + const capabilities = JSON.stringify(safeCapabilities(body.capabilities)); + const placement = body.placement === "managed" ? "managed" : "local"; + const runnerVersion = String(body.runnerVersion || "").slice(0, 80); + + await c.env.DB.prepare( + `INSERT INTO instance_runtimes ( + instance_id, user_id, placement, endpoint_url, + token_ciphertext, token_dek_wrapped, token_iv, token_plaintext, + capabilities, runner_version, status, last_seen_at, created_at, updated_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'registered', datetime('now'), datetime('now'), datetime('now')) + ON CONFLICT(instance_id) DO UPDATE SET + placement = excluded.placement, + endpoint_url = excluded.endpoint_url, + token_ciphertext = excluded.token_ciphertext, + token_dek_wrapped = excluded.token_dek_wrapped, + token_iv = excluded.token_iv, + token_plaintext = excluded.token_plaintext, + capabilities = excluded.capabilities, + runner_version = excluded.runner_version, + status = 'registered', + last_seen_at = datetime('now'), + updated_at = datetime('now')`, + ) + .bind( + instanceId, + session.uid, + placement, + endpointUrl, + tokenParts.ciphertext, + tokenParts.dekWrapped, + tokenParts.iv, + tokenParts.plaintext, + capabilities, + runnerVersion, + ) + .run(); + + const runtime = await requireRuntime(c.env, instanceId, session.uid); + return c.json({ runtime: runtimeResponse(runtime) }, 201); +}); + +/** Read my registered runtime without exposing its token. */ +instanceRoutes.get("/:instanceId/runtime", async (c) => { + const session = await requireUser(c); + const instanceId = c.req.param("instanceId"); + await requireOwnedInstance(c.env, instanceId, session.uid); + const runtime = await getRuntime(c.env, instanceId, session.uid); + return c.json({ runtime: runtime ? runtimeResponse(runtime) : null }); +}); + +/** Heartbeat from user/CLI after checking the local runner is online. */ +instanceRoutes.post("/:instanceId/runtime/heartbeat", async (c) => { + const session = await requireUser(c); + const instanceId = c.req.param("instanceId"); + await requireOwnedInstance(c.env, instanceId, session.uid); + await requireRuntime(c.env, instanceId, session.uid); + await updateRuntimeStatus(c.env, instanceId, session.uid, "online"); + return c.json({ success: true, status: "online" }); +}); + +/** Probe a registered runtime's health and capabilities through PAGS. */ +instanceRoutes.get("/:instanceId/runtime/status", async (c) => { + const session = await requireUser(c); + const instanceId = c.req.param("instanceId"); + await requireOwnedInstance(c.env, instanceId, session.uid); + const runtime = await requireRuntime(c.env, instanceId, session.uid); + + try { + const [healthRes, capabilitiesRes] = await Promise.all([ + callRuntime(c.env, runtime, "/health"), + callRuntime(c.env, runtime, "/capabilities"), + ]); + const health = await healthRes.json().catch(() => ({})); + const capabilities = await capabilitiesRes.json().catch(() => ({})); + const online = healthRes.ok && capabilitiesRes.ok; + await updateRuntimeStatus(c.env, instanceId, session.uid, online ? "online" : "offline"); + return c.json({ + runtime: runtimeResponse({ + ...runtime, + status: online ? "online" : "offline", + last_seen_at: new Date().toISOString(), + }), + health, + capabilities, + }); + } catch (error) { + await updateRuntimeStatus(c.env, instanceId, session.uid, "offline"); + return c.json({ + runtime: runtimeResponse({ ...runtime, status: "offline" }), + error: error instanceof Error ? error.message : String(error), + }, 502); + } +}); + +/** Remove my registered runtime. */ +instanceRoutes.delete("/:instanceId/runtime", async (c) => { + const session = await requireUser(c); + const instanceId = c.req.param("instanceId"); + await requireOwnedInstance(c.env, instanceId, session.uid); + await c.env.DB.prepare( + "DELETE FROM instance_runtimes WHERE instance_id = ?1 AND user_id = ?2", + ) + .bind(instanceId, session.uid) + .run(); + return c.json({ success: true }); +}); + +/** Create a task on my registered runtime. */ +instanceRoutes.post("/:instanceId/tasks", async (c) => { + const session = await requireUser(c); + const instanceId = c.req.param("instanceId"); + await requireOwnedInstance(c.env, instanceId, session.uid); + const runtime = await requireRuntime(c.env, instanceId, session.uid); + const body = normalizeRunnerTaskBody(await c.req.json()); + const res = await callRuntime(c.env, runtime, "/tasks", { + method: "POST", + body: JSON.stringify(body), + }); + return c.json(await runtimeJson(res), runtimeStatus(res, 202)); +}); + +/** Read a task from my registered runtime. */ +instanceRoutes.get("/:instanceId/tasks/:taskId", async (c) => { + const session = await requireUser(c); + const instanceId = c.req.param("instanceId"); + await requireOwnedInstance(c.env, instanceId, session.uid); + const runtime = await requireRuntime(c.env, instanceId, session.uid); + const res = await callRuntime(c.env, runtime, `/tasks/${encodeURIComponent(c.req.param("taskId"))}`); + return c.json(await runtimeJson(res), runtimeStatus(res, 200)); +}); + +/** Approve a task waiting on local human approval. */ +instanceRoutes.post("/:instanceId/tasks/:taskId/approve", async (c) => { + const session = await requireUser(c); + const instanceId = c.req.param("instanceId"); + await requireOwnedInstance(c.env, instanceId, session.uid); + const runtime = await requireRuntime(c.env, instanceId, session.uid); + const res = await callRuntime( + c.env, + runtime, + `/tasks/${encodeURIComponent(c.req.param("taskId"))}/approve`, + { method: "POST" }, + ); + return c.json(await runtimeJson(res), runtimeStatus(res, 200)); +}); + +/** Cancel a runtime task. */ +instanceRoutes.post("/:instanceId/tasks/:taskId/cancel", async (c) => { + const session = await requireUser(c); + const instanceId = c.req.param("instanceId"); + await requireOwnedInstance(c.env, instanceId, session.uid); + const runtime = await requireRuntime(c.env, instanceId, session.uid); + const res = await callRuntime( + c.env, + runtime, + `/tasks/${encodeURIComponent(c.req.param("taskId"))}/cancel`, + { method: "POST" }, + ); + return c.json(await runtimeJson(res), runtimeStatus(res, 200)); +}); + +/** Read recent task events from my registered runtime. */ +instanceRoutes.get("/:instanceId/task-events", async (c) => { + const session = await requireUser(c); + const instanceId = c.req.param("instanceId"); + await requireOwnedInstance(c.env, instanceId, session.uid); + const runtime = await requireRuntime(c.env, instanceId, session.uid); + const limit = c.req.query("limit") || "100"; + const res = await callRuntime(c.env, runtime, `/events?limit=${encodeURIComponent(limit)}`); + return c.json(await runtimeJson(res), runtimeStatus(res, 200)); +}); + /** Chat with my instance of an agent. */ instanceRoutes.post("/:instanceId/chat", async (c) => { const session = await requireUser(c); diff --git a/workers/host/build.js b/workers/host/build.js index c50cc1ca..7f96f9c2 100644 --- a/workers/host/build.js +++ b/workers/host/build.js @@ -25,6 +25,10 @@ const pages = { path.join(storeDir, "skills", "proagentstore-mcp-operator", "index.html"), "utf-8", ), + browserRuntimeDocsPage: fs.readFileSync( + path.join(storeDir, "docs", "browser-runtime", "index.html"), + "utf-8", + ), consolePage: fs.readFileSync( path.join(storeDir, "console", "index.html"), "utf-8", diff --git a/workers/host/src/index.ts b/workers/host/src/index.ts index 63bd0006..8af0cd39 100644 --- a/workers/host/src/index.ts +++ b/workers/host/src/index.ts @@ -3,7 +3,7 @@ * Pages inlined from store/ at build time via build.js → pages.ts. */ import { - homepage, aboutPage, getStartedPage, skillsPage, skillMcpOperatorPage, consolePage, agentDetailPage, + homepage, aboutPage, getStartedPage, skillsPage, skillMcpOperatorPage, browserRuntimeDocsPage, consolePage, agentDetailPage, widgetJs, authWidgetJs, developerProfilePage, adminPage, notFoundPage, changelogPage, openapiYaml, llmsTxt, llmsFullTxt, skillsJson, faviconSvg, manifestJson, @@ -20,6 +20,8 @@ const PAGES: Record = { "/skills/": skillsPage, "/skills/proagentstore-mcp-operator": skillMcpOperatorPage, "/skills/proagentstore-mcp-operator/": skillMcpOperatorPage, + "/docs/browser-runtime": browserRuntimeDocsPage, + "/docs/browser-runtime/": browserRuntimeDocsPage, "/console": consolePage, "/console/": consolePage, "/admin": adminPage, diff --git a/workers/mcp/src/index-auth.test.ts b/workers/mcp/src/index-auth.test.ts index 0806e21e..edfc3455 100644 --- a/workers/mcp/src/index-auth.test.ts +++ b/workers/mcp/src/index-auth.test.ts @@ -66,7 +66,7 @@ describe("MCP transport auth", () => { await expect(res.json()).resolves.toMatchObject({ ok: true, service: "proagentstore-mcp", - tools: 26, + tools: 35, }); }); }); diff --git a/workers/mcp/src/index.ts b/workers/mcp/src/index.ts index 93113a4b..5c47e601 100644 --- a/workers/mcp/src/index.ts +++ b/workers/mcp/src/index.ts @@ -471,6 +471,59 @@ export class PagsMcp extends McpAgent { }, ); + this.server.tool( + "get_agent_board_config", + "Read the authenticated creator's configurable console kanban board for agents.", + { token: z.string().optional().describe("PAGS session token. Omit when connected with browser sign-in.") }, + async ({ token }) => { + const sessionToken = this.token(token); + if (!sessionToken) return authRequired(); + const data = (await authedCall( + "/v1/auth/me", + sessionToken, + {}, + this.env, + )) as { boardConfig?: unknown; error?: string }; + if (data.error) return text(`Error: ${data.error}`); + return jsonText(data.boardConfig || null); + }, + ); + + this.server.tool( + "update_agent_board_config", + "Update the authenticated creator's console kanban board. Columns match agent statuses and visibilities in order.", + { + token: z.string().optional().describe("PAGS session token. Omit when connected with browser sign-in."), + config: z.object({ + summary: z.string().optional(), + columns: z.array(z.object({ + id: z.string(), + title: z.string(), + color: z.string().optional(), + empty: z.string().optional(), + statuses: z.array(z.string()).optional(), + visibilities: z.array(z.string()).optional(), + excludeStatuses: z.array(z.string()).optional(), + excludeVisibilities: z.array(z.string()).optional(), + catchAll: z.boolean().optional(), + })).min(1).max(8), + }), + }, + async ({ token, config }) => { + const sessionToken = this.token(token); + if (!sessionToken) return authRequired(); + const data = (await authedCall( + "/v1/auth/me", + sessionToken, + { method: "PUT", body: JSON.stringify({ board_config: config }) }, + this.env, + )) as { success?: boolean; error?: string }; + return data.success + ? text("Updated agent board config.") + : text(`Error: ${data.error || "update failed"}`); + }, + ); + registerInstanceTools(this.server, this.env, (provided) => this.token(provided)); this.server.tool( @@ -888,8 +941,8 @@ Marketplace for server-powered AI agents. Creators build agent templates, client ## Agent Types: Agents | Workers | Tools ## CLI: pags init --template worker|cron|api, pags check, pags publish -## MCP creator tools: scaffold_agent, list_agent_files, read_agent_file, write_agent_file, batch_write_agent_files, trigger_agent_deploy, agent_deploy_status -## MCP runtime tools: subscribe_agent, my_instances, add_instance_knowledge, chat_with_instance, instance_messages +## MCP creator tools: scaffold_agent, list_agent_files, read_agent_file, write_agent_file, batch_write_agent_files, get_agent_board_config, update_agent_board_config, trigger_agent_deploy, agent_deploy_status +## MCP runtime tools: subscribe_agent, my_instances, add_instance_knowledge, chat_with_instance, instance_messages, register_instance_runtime, instance_runtime_status, unregister_instance_runtime, run_instance_task, approve_instance_task, cancel_instance_task, instance_task_events ## Public trial: chat_with_agent calls /v1/public/agents/:id/try and is for previews, not the main user runtime ## URLs: Store proagentstore.online, API api.proagentstore.online, MCP mcp.proagentstore.online/mcp ## Key endpoints: GET /v1/agents, POST /v1/public/agents/:id/try, POST /v1/instances/:id/subscribe, POST /v1/instances/:instanceId/chat`; @@ -946,12 +999,12 @@ export default { } if (url.pathname === "/health") { return new Response( - JSON.stringify({ ok: true, service: "proagentstore-mcp", tools: 26 }), + JSON.stringify({ ok: true, service: "proagentstore-mcp", tools: 35 }), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "https://proagentstore.online" } }, ); } return new Response( - "ProAgentStore MCP Server\n\nConnect: npx mcp-remote https://mcp.proagentstore.online/mcp\n\nUse chat_with_agent for public trial previews. Use subscribe_agent, my_instances, add_instance_knowledge, and chat_with_instance for the real private instance runtime.\n\nTools include: list_agents, my_agents, my_instances, subscribe_agent, chat_with_instance, scaffold_agent, create_agent, update_agent, list/read/write agent files, add/list knowledge, analytics, deploy status, platform guide, SDK reference.", + "ProAgentStore MCP Server\n\nConnect: npx mcp-remote https://mcp.proagentstore.online/mcp\n\nUse chat_with_agent for public trial previews. Use subscribe_agent, my_instances, add_instance_knowledge, and chat_with_instance for text private instances. Use register_instance_runtime, run_instance_task, approve_instance_task, cancel_instance_task, and instance_task_events for browser-capable private instances.\n\nTools include: list_agents, my_agents, my_instances, subscribe_agent, chat_with_instance, register/manage instance runtimes, run/approve/cancel instance tasks, scaffold_agent, create_agent, update_agent, get/update agent board config, list/read/write agent files, add/list knowledge, analytics, deploy status, platform guide, SDK reference.", { headers: { "Content-Type": "text/plain" } }, ); }, diff --git a/workers/mcp/src/instance-tools.ts b/workers/mcp/src/instance-tools.ts index a9c88f13..86c6d860 100644 --- a/workers/mcp/src/instance-tools.ts +++ b/workers/mcp/src/instance-tools.ts @@ -113,6 +113,184 @@ export function registerInstanceTools( }, ); + server.tool( + "register_instance_runtime", + "Register a local or managed browser runner for one of your private instances. Use this before run_instance_task for browser-capable agents.", + { + token: z.string().optional().describe("PAGS session token. Omit when connected with browser sign-in."), + instance_id: z.string(), + endpoint_url: z.string().describe("HTTPS tunnel URL for the runner, or localhost URL for development."), + runner_token: z.string().optional().describe("Bearer token configured on the runner."), + placement: z.enum(["local", "managed"]).optional(), + capabilities: z.array(z.string()).optional(), + runner_version: z.string().optional(), + }, + async ({ + token, + instance_id, + endpoint_url, + runner_token, + placement, + capabilities, + runner_version, + }) => { + const sessionToken = tokenFor(token); + if (!sessionToken) return authRequired(); + const data = (await authedCall( + `/v1/instances/${instance_id}/runtime`, + sessionToken, + { + method: "POST", + body: JSON.stringify({ + endpointUrl: endpoint_url, + token: runner_token, + placement: placement || "local", + capabilities: capabilities || [], + runnerVersion: runner_version || "", + }), + }, + env, + )) as { runtime?: unknown; error?: string }; + return data.error + ? text(`Error: ${data.error}`) + : text(`Runtime registered for ${instance_id}.\n${JSON.stringify(data.runtime, null, 2)}`); + }, + ); + + server.tool( + "instance_runtime_status", + "Check the registered local or managed runtime for one of your private instances.", + { + token: z.string().optional().describe("PAGS session token. Omit when connected with browser sign-in."), + instance_id: z.string(), + probe: z.boolean().optional().describe("When true, PAGS calls the runner /health and /capabilities endpoints."), + }, + async ({ token, instance_id, probe }) => { + const sessionToken = tokenFor(token); + if (!sessionToken) return authRequired(); + const path = probe + ? `/v1/instances/${instance_id}/runtime/status` + : `/v1/instances/${instance_id}/runtime`; + const data = await authedCall(path, sessionToken, {}, env); + return jsonText(data); + }, + ); + + server.tool( + "unregister_instance_runtime", + "Remove the registered runtime endpoint for one of your private instances.", + { + token: z.string().optional().describe("PAGS session token. Omit when connected with browser sign-in."), + instance_id: z.string(), + }, + async ({ token, instance_id }) => { + const sessionToken = tokenFor(token); + if (!sessionToken) return authRequired(); + const data = (await authedCall( + `/v1/instances/${instance_id}/runtime`, + sessionToken, + { method: "DELETE" }, + env, + )) as { success?: boolean; error?: string }; + return text(data.success ? "Runtime unregistered." : `Error: ${data.error || "unregister failed"}`); + }, + ); + + server.tool( + "run_instance_task", + "Create a task on the registered local or managed runner for a private instance. The PAGS brain stays in control; the runner executes local capabilities.", + { + token: z.string().optional().describe("PAGS session token. Omit when connected with browser sign-in."), + instance_id: z.string(), + type: z.string().describe("Runner task type, e.g. echo or browser.open."), + input: z.record(z.unknown()).optional(), + requires_approval: z.boolean().optional(), + approval_prompt: z.string().optional(), + }, + async ({ token, instance_id, type, input, requires_approval, approval_prompt }) => { + const sessionToken = tokenFor(token); + if (!sessionToken) return authRequired(); + const data = await authedCall( + `/v1/instances/${instance_id}/tasks`, + sessionToken, + { + method: "POST", + body: JSON.stringify({ + type, + input: input || {}, + requiresApproval: requires_approval, + approvalPrompt: approval_prompt, + }), + }, + env, + ); + return jsonText(data); + }, + ); + + server.tool( + "approve_instance_task", + "Approve a runner task waiting for human approval.", + { + token: z.string().optional().describe("PAGS session token. Omit when connected with browser sign-in."), + instance_id: z.string(), + task_id: z.string(), + }, + async ({ token, instance_id, task_id }) => { + const sessionToken = tokenFor(token); + if (!sessionToken) return authRequired(); + const data = await authedCall( + `/v1/instances/${instance_id}/tasks/${task_id}/approve`, + sessionToken, + { method: "POST" }, + env, + ); + return jsonText(data); + }, + ); + + server.tool( + "cancel_instance_task", + "Cancel a task on the registered local or managed runner for a private instance.", + { + token: z.string().optional().describe("PAGS session token. Omit when connected with browser sign-in."), + instance_id: z.string(), + task_id: z.string(), + }, + async ({ token, instance_id, task_id }) => { + const sessionToken = tokenFor(token); + if (!sessionToken) return authRequired(); + const data = await authedCall( + `/v1/instances/${instance_id}/tasks/${task_id}/cancel`, + sessionToken, + { method: "POST" }, + env, + ); + return jsonText(data); + }, + ); + + server.tool( + "instance_task_events", + "Read recent events from a private instance's registered runner.", + { + token: z.string().optional().describe("PAGS session token. Omit when connected with browser sign-in."), + instance_id: z.string(), + limit: z.number().int().min(1).max(500).optional(), + }, + async ({ token, instance_id, limit }) => { + const sessionToken = tokenFor(token); + if (!sessionToken) return authRequired(); + const data = await authedCall( + `/v1/instances/${instance_id}/task-events?limit=${limit || 100}`, + sessionToken, + {}, + env, + ); + return jsonText(data); + }, + ); + server.tool( "instance_messages", "Read recent messages from one of your private subscribed instances.",