@proagentstore/cli 0.4.96 → 0.4.98
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser-runner/local-apply/contract.js +18 -6
- package/dist/browser-runner/local-apply/live-run-browser.js +101 -0
- package/dist/browser-runner/local-apply/reconciliation-routes.js +24 -0
- package/dist/browser-runner/local-apply/reconciliation-runtime.js +210 -0
- package/dist/browser-runner/local-apply/runtime-handoff.js +4 -1
- package/dist/browser-runner/local-apply/takeover-adapter.js +3 -5
- package/dist/browser-runner/runner.js +11 -12
- package/dist/browser-runner/server.js +4 -0
- package/package.json +1 -1
|
@@ -46,6 +46,18 @@ export const LOCAL_APPLY_HANDOFF_FRAME_PATH = "/local-apply/handoff/frame";
|
|
|
46
46
|
export const LOCAL_APPLY_HANDOFF_INPUT_PATH = "/local-apply/handoff/input";
|
|
47
47
|
export const LOCAL_APPLY_HANDOFF_RESUME_PATH = "/local-apply/handoff/resume";
|
|
48
48
|
export const LOCAL_APPLY_HANDOFF_END_PATH = "/local-apply/handoff/end";
|
|
49
|
+
/**
|
|
50
|
+
* A separate, browser-only receipt/history check for an already-ended uncertain attempt. This is
|
|
51
|
+
* deliberately not an application run: it receives no engine, sources, artifacts or submit gate.
|
|
52
|
+
*/
|
|
53
|
+
export const LOCAL_APPLY_RECONCILIATION_RUN_PATH = "/local-apply/reconciliation/run";
|
|
54
|
+
export const LOCAL_APPLY_RECONCILIATION_STATUS_PATH = "/local-apply/reconciliation/status";
|
|
55
|
+
export const LOCAL_APPLY_RECONCILIATION_HANDOFF_PATH = "/local-apply/reconciliation/handoff";
|
|
56
|
+
export const LOCAL_APPLY_RECONCILIATION_HANDOFF_STATUS_PATH = "/local-apply/reconciliation/handoff-status";
|
|
57
|
+
export const LOCAL_APPLY_RECONCILIATION_HANDOFF_FRAME_PATH = "/local-apply/reconciliation/handoff/frame";
|
|
58
|
+
export const LOCAL_APPLY_RECONCILIATION_HANDOFF_INPUT_PATH = "/local-apply/reconciliation/handoff/input";
|
|
59
|
+
export const LOCAL_APPLY_RECONCILIATION_HANDOFF_RESUME_PATH = "/local-apply/reconciliation/handoff/resume";
|
|
60
|
+
export const LOCAL_APPLY_RECONCILIATION_HANDOFF_END_PATH = "/local-apply/reconciliation/handoff/end";
|
|
49
61
|
export const LOCAL_APPLY_ENGINES = ["claude", "codex"];
|
|
50
62
|
export const LOCAL_APPLY_AUTH_MODES = ["machine", "subscription"];
|
|
51
63
|
export const LOCAL_APPLY_MODES = ["fill_and_review", "auto_submit"];
|
|
@@ -95,12 +107,12 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
|
|
|
95
107
|
* dispatch, naming the update, rather than silently producing the older behaviour (`apply.ts`
|
|
96
108
|
* `runnerContractProblem`).
|
|
97
109
|
*
|
|
98
|
-
* ── Why it moved to 0.4.
|
|
110
|
+
* ── Why it moved to 0.4.97 (#1013)
|
|
99
111
|
*
|
|
100
|
-
* #
|
|
101
|
-
*
|
|
102
|
-
* A machine below this floor cannot
|
|
103
|
-
*
|
|
112
|
+
* #1013 adds the exact isolated-run browser handoff used when a sign-in or CAPTCHA blocks an
|
|
113
|
+
* application. That route lives in `packages/browser-runner`, which ships INSIDE the published
|
|
114
|
+
* CLI. A machine below this floor cannot retain the actual isolated page and must not silently
|
|
115
|
+
* substitute the shared/default browser page.
|
|
104
116
|
*
|
|
105
117
|
* A behaviour change in the runner is a CONTRACT change, because the cloud's decisions assume it.
|
|
106
118
|
* So this floor moves with it: #1013's exact-page login/CAPTCHA handoff and post-submit receipt
|
|
@@ -111,7 +123,7 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
|
|
|
111
123
|
* been published. `cliAtLeast` treats an unreported version as capable, which is the convention
|
|
112
124
|
* every MIN_CLI gate here follows.
|
|
113
125
|
*/
|
|
114
|
-
export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.
|
|
126
|
+
export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.97";
|
|
115
127
|
export const LOCAL_APPLY_DIAGNOSTIC_CAUSES = ["bridge_unused", "engine_exited_nonzero", "timed_out", "no_engine_output", "submit_unconfirmed"];
|
|
116
128
|
export const LOCAL_APPLY_CONFIRMATION_MARKERS = ["page_text", "url_receipt", "already_applied_notice"];
|
|
117
129
|
export const LOCAL_APPLY_SIGNALS = [
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { McpRuntime } from "../mcp-runtime.js";
|
|
4
|
+
/** Bind application handoff to the run browser, not to the runner-wide active page. */
|
|
5
|
+
export function createLocalApplyBrowserFactory(options) {
|
|
6
|
+
return async (profile, runDir) => {
|
|
7
|
+
if (profile === "isolated")
|
|
8
|
+
return createIsolatedApplyBrowser(runDir, options.headless);
|
|
9
|
+
const browser = await options.defaultBrowser(profile, runDir);
|
|
10
|
+
return { ...browser, handoffPage: options.sharedPage };
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Keep local-apply's browser construction out of `runner.ts`: research uses the generic factory,
|
|
15
|
+
* while application runs get a page-owning isolated context for the scoped handoff.
|
|
16
|
+
*/
|
|
17
|
+
export function createRunnerBrowserFactories(options) {
|
|
18
|
+
const browserFor = async (profile, runDir) => {
|
|
19
|
+
if (profile === "default")
|
|
20
|
+
return { tools: await options.defaultTools(), stop: async () => undefined };
|
|
21
|
+
const mcp = new McpRuntime();
|
|
22
|
+
await mcp.start({ userDataDir: join(runDir, "profile"), headless: options.headless });
|
|
23
|
+
return { tools: mcp, stop: () => mcp.stop() };
|
|
24
|
+
};
|
|
25
|
+
return { browserFor, applyBrowserFor: createLocalApplyBrowserFactory({ headless: options.headless, defaultBrowser: browserFor, sharedPage: options.sharedPage }) };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Start the disposable browser context for one application run and retain only its current live
|
|
29
|
+
* page for the bounded takeover adapter. This deliberately does not seed, copy, or persist an
|
|
30
|
+
* owner profile: the profile remains the run's existing throwaway `runDir/profile` directory.
|
|
31
|
+
*/
|
|
32
|
+
export async function createIsolatedApplyBrowser(runDir, headless) {
|
|
33
|
+
const playwright = await import("playwright");
|
|
34
|
+
const profileDir = join(runDir, "profile");
|
|
35
|
+
let context;
|
|
36
|
+
let mcp;
|
|
37
|
+
let activePage = null;
|
|
38
|
+
let stopped = false;
|
|
39
|
+
try {
|
|
40
|
+
context = await playwright.chromium.launchPersistentContext(profileDir, {
|
|
41
|
+
headless,
|
|
42
|
+
acceptDownloads: true,
|
|
43
|
+
viewport: null,
|
|
44
|
+
args: [
|
|
45
|
+
"--disable-blink-features=AutomationControlled",
|
|
46
|
+
"--start-maximized",
|
|
47
|
+
"--window-size=1512,982",
|
|
48
|
+
"--remote-debugging-port=0",
|
|
49
|
+
"--use-fake-ui-for-media-stream",
|
|
50
|
+
"--use-fake-device-for-media-stream",
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
await context.addInitScript(() => {
|
|
54
|
+
Object.defineProperty(navigator, "webdriver", { get: () => undefined });
|
|
55
|
+
}).catch(() => undefined);
|
|
56
|
+
const track = (page) => {
|
|
57
|
+
activePage = page;
|
|
58
|
+
// Never fall back to another tab after the exact page closes: that would hand a
|
|
59
|
+
// person a different site/page. A lost page is terminal for this handoff.
|
|
60
|
+
page.once("close", () => {
|
|
61
|
+
if (activePage === page)
|
|
62
|
+
activePage = null;
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
context.on("page", track);
|
|
66
|
+
const initial = context.pages().at(-1) ?? await context.newPage();
|
|
67
|
+
track(initial);
|
|
68
|
+
mcp = new McpRuntime();
|
|
69
|
+
await mcp.start({ cdpEndpoint: await cdpEndpoint(profileDir) });
|
|
70
|
+
return {
|
|
71
|
+
tools: mcp,
|
|
72
|
+
handoffPage: () => activePage && !activePage.isClosed() ? activePage : null,
|
|
73
|
+
stop: async () => {
|
|
74
|
+
if (stopped)
|
|
75
|
+
return;
|
|
76
|
+
stopped = true;
|
|
77
|
+
activePage = null;
|
|
78
|
+
await mcp.stop().catch(() => undefined);
|
|
79
|
+
await context.close().catch(() => undefined);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
await mcp?.stop().catch(() => undefined);
|
|
85
|
+
await context?.close().catch(() => undefined);
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function cdpEndpoint(profileDir) {
|
|
90
|
+
const portFile = join(profileDir, "DevToolsActivePort");
|
|
91
|
+
for (let i = 0; i < 50; i++) {
|
|
92
|
+
if (existsSync(portFile)) {
|
|
93
|
+
const text = await import("node:fs/promises").then(({ readFile }) => readFile(portFile, "utf8"));
|
|
94
|
+
const port = text.split("\n", 1)[0]?.trim();
|
|
95
|
+
if (port)
|
|
96
|
+
return `http://127.0.0.1:${port}`;
|
|
97
|
+
}
|
|
98
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
99
|
+
}
|
|
100
|
+
throw new Error("could not read the local application browser CDP port");
|
|
101
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Separate from apply and generic tasks: this surface has no bridge/fill/submit operation. */
|
|
2
|
+
export async function routeLocalApplyReconciliation(runtime, method, path, read) {
|
|
3
|
+
if (method !== "POST")
|
|
4
|
+
return undefined;
|
|
5
|
+
if (path === "/local-apply/reconciliation/run")
|
|
6
|
+
return runtime.start(await read());
|
|
7
|
+
if (path === "/local-apply/reconciliation/status")
|
|
8
|
+
return runtime.status(await read());
|
|
9
|
+
if (path === "/local-apply/reconciliation/handoff")
|
|
10
|
+
return runtime.handoff(await read());
|
|
11
|
+
if (path === "/local-apply/reconciliation/handoff-status")
|
|
12
|
+
return runtime.status(await read());
|
|
13
|
+
if (path === "/local-apply/reconciliation/handoff/frame")
|
|
14
|
+
return runtime.handoffFrame(await read());
|
|
15
|
+
if (path === "/local-apply/reconciliation/handoff/input") {
|
|
16
|
+
await runtime.handoffInput(await read());
|
|
17
|
+
return { ok: true };
|
|
18
|
+
}
|
|
19
|
+
if (path === "/local-apply/reconciliation/handoff/resume")
|
|
20
|
+
return runtime.resume(await read());
|
|
21
|
+
if (path === "/local-apply/reconciliation/handoff/end")
|
|
22
|
+
return runtime.end(await read());
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only reconciliation for an ended `submit_unconfirmed` attempt.
|
|
3
|
+
*
|
|
4
|
+
* This is intentionally independent of ApplyBridge: it launches no CLI, receives no source or
|
|
5
|
+
* artifact paths and has no action surface capable of filling, uploading or submitting. The only
|
|
6
|
+
* page inspection remains in this process and returns a closed evidence vocabulary to PAGS.
|
|
7
|
+
*/
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { RunnerInputError } from "../errors.js";
|
|
10
|
+
import { domainWithin } from "../local-browser/bridge.js";
|
|
11
|
+
const SAFE_ID = /^[A-Za-z0-9_.:-]{1,300}$/;
|
|
12
|
+
const TTL_MS = 10 * 60_000;
|
|
13
|
+
/** Dedicated runtime; there is deliberately no bridge, engine, or generic browser-task fallback. */
|
|
14
|
+
export class LocalApplyReconciliationRuntime {
|
|
15
|
+
deps;
|
|
16
|
+
sessions = new Map();
|
|
17
|
+
now;
|
|
18
|
+
constructor(deps) {
|
|
19
|
+
this.deps = deps;
|
|
20
|
+
this.now = deps.now ?? (() => Date.now());
|
|
21
|
+
}
|
|
22
|
+
async start(raw) {
|
|
23
|
+
const envelope = parseEnvelope(raw);
|
|
24
|
+
const existing = this.sessions.get(envelope.reconciliationId);
|
|
25
|
+
if (existing) {
|
|
26
|
+
if (existing.envelope.runId !== envelope.runId || existing.envelope.applicationId !== envelope.applicationId || existing.envelope.instanceId !== envelope.instanceId || existing.envelope.browserProfile !== envelope.browserProfile)
|
|
27
|
+
throw new RunnerInputError("The reconciliation id is already bound to another attempt", 409);
|
|
28
|
+
return this.view(existing);
|
|
29
|
+
}
|
|
30
|
+
const browser = await this.deps.browserFor(envelope.browserProfile, join(this.deps.dataDir, "reconciliation", envelope.reconciliationId));
|
|
31
|
+
const session = { envelope, browser, state: "running", expiresAt: this.now() + TTL_MS };
|
|
32
|
+
this.sessions.set(envelope.reconciliationId, session);
|
|
33
|
+
try {
|
|
34
|
+
const page = this.page(session);
|
|
35
|
+
if (!page)
|
|
36
|
+
return await this.finish(session, { state: "unavailable", proofKind: "authorized_profile_unavailable" });
|
|
37
|
+
await page.goto(envelope.applicationUrl, { waitUntil: "domcontentloaded", timeout: 20_000 });
|
|
38
|
+
return await this.inspect(session);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return this.finish(session, { state: "unavailable", proofKind: "authorized_profile_unavailable" });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async status(raw) {
|
|
45
|
+
const session = this.require(raw);
|
|
46
|
+
if (session.state === "ended")
|
|
47
|
+
return this.view(session);
|
|
48
|
+
if (session.expiresAt <= this.now())
|
|
49
|
+
return this.finish(session, { state: "ambiguous", proofKind: "ambiguous_site_history" });
|
|
50
|
+
if (!this.page(session))
|
|
51
|
+
return this.finish(session, { state: "unavailable", proofKind: "authorized_profile_unavailable" });
|
|
52
|
+
if (session.handoff?.state === "ready" && (session.handoff.expiresAt <= this.now() || !this.deps.takeover || await this.deps.takeover.state(session.handoff.id) !== "ready"))
|
|
53
|
+
await this.closeHandoff(session, session.handoff.expiresAt <= this.now() ? "expired" : "page_lost");
|
|
54
|
+
return this.view(session);
|
|
55
|
+
}
|
|
56
|
+
async handoff(raw) {
|
|
57
|
+
const request = this.handoffRequest(raw);
|
|
58
|
+
const session = this.require(request);
|
|
59
|
+
this.assertHandoffBinding(session, request);
|
|
60
|
+
if (session.state !== "paused" || !session.pauseReason)
|
|
61
|
+
throw new RunnerInputError("Reconciliation handoff is available only for a live login or CAPTCHA pause", 409);
|
|
62
|
+
if (session.handoff)
|
|
63
|
+
throw new RunnerInputError("A reconciliation handoff already exists", 409);
|
|
64
|
+
const page = this.page(session);
|
|
65
|
+
if (!page || !this.deps.takeover)
|
|
66
|
+
return this.closedHandoff(session, request.handoffId, "profile_unavailable");
|
|
67
|
+
try {
|
|
68
|
+
await this.deps.takeover.open(request, page);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return this.closedHandoff(session, request.handoffId, "page_lost");
|
|
72
|
+
}
|
|
73
|
+
session.handoff = { id: request.handoffId, expiresAt: this.now() + TTL_MS, state: "ready" };
|
|
74
|
+
return this.view(session);
|
|
75
|
+
}
|
|
76
|
+
async handoffFrame(raw) {
|
|
77
|
+
const session = await this.liveHandoff(raw);
|
|
78
|
+
try {
|
|
79
|
+
return await this.deps.takeover.frame(session.handoff.id);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
await this.closeHandoff(session, "page_lost");
|
|
83
|
+
throw new RunnerInputError("The reconciliation page is no longer available", 409);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async handoffInput(raw) {
|
|
87
|
+
const request = raw && typeof raw === "object" ? raw : {};
|
|
88
|
+
const session = await this.liveHandoff(request);
|
|
89
|
+
if (!request.input || typeof request.input !== "object")
|
|
90
|
+
throw new RunnerInputError("handoff input is required");
|
|
91
|
+
// A person may authenticate/solve a CAPTCHA, but once that wall is gone this session accepts
|
|
92
|
+
// no further controls. It can never become an application fill/submit surface.
|
|
93
|
+
const blocker = await this.blocker(this.page(session));
|
|
94
|
+
if (!blocker)
|
|
95
|
+
throw new RunnerInputError("The read-only reconciliation handoff no longer accepts browser input", 409);
|
|
96
|
+
try {
|
|
97
|
+
await this.deps.takeover.input(session.handoff.id, request.input);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
await this.closeHandoff(session, "page_lost");
|
|
101
|
+
throw new RunnerInputError("The reconciliation page is no longer available", 409);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async resume(raw) {
|
|
105
|
+
const session = await this.liveHandoff(raw);
|
|
106
|
+
return this.inspect(session);
|
|
107
|
+
}
|
|
108
|
+
async end(raw) {
|
|
109
|
+
const session = this.require(raw);
|
|
110
|
+
if (session.handoff?.state === "ready")
|
|
111
|
+
await this.closeHandoff(session, "unavailable");
|
|
112
|
+
session.state = "running";
|
|
113
|
+
session.pauseReason = undefined;
|
|
114
|
+
return this.view(session);
|
|
115
|
+
}
|
|
116
|
+
liveWork() { return [...this.sessions.values()].filter((s) => s.state !== "ended").map((s) => `reconciliation:${s.envelope.reconciliationId}`); }
|
|
117
|
+
closeAll() { for (const session of this.sessions.values())
|
|
118
|
+
void this.finish(session, { state: "unavailable", proofKind: "authorized_profile_unavailable" }); }
|
|
119
|
+
async inspect(session) {
|
|
120
|
+
const page = this.page(session);
|
|
121
|
+
if (!page)
|
|
122
|
+
return this.finish(session, { state: "unavailable", proofKind: "authorized_profile_unavailable" });
|
|
123
|
+
const state = await this.pageState(page).catch(() => null);
|
|
124
|
+
if (!state || !this.permitted(session, state.url))
|
|
125
|
+
return this.finish(session, { state: "ambiguous", proofKind: "ambiguous_site_history" });
|
|
126
|
+
if (state.login || state.captcha) {
|
|
127
|
+
session.state = "paused";
|
|
128
|
+
session.pauseReason = state.captcha ? "captcha" : "login_required";
|
|
129
|
+
return this.view(session);
|
|
130
|
+
}
|
|
131
|
+
// A handoff which has just cleared authentication is the one case where we retain the
|
|
132
|
+
// bounded browser. It lets the owner inspect their authenticated account/job view without
|
|
133
|
+
// turning generic page prose into an outcome. Explicit end or the ten-minute deadline
|
|
134
|
+
// resolves it as ambiguous unless a future provider-specific verifier supplies exact proof.
|
|
135
|
+
if (session.handoff?.state === "ready") {
|
|
136
|
+
session.state = "running";
|
|
137
|
+
session.pauseReason = undefined;
|
|
138
|
+
return this.view(session);
|
|
139
|
+
}
|
|
140
|
+
// SEEK has no validated account/job receipt or history contract in this runtime. Generic
|
|
141
|
+
// prose, receipt-like URLs, unauthenticated pages and absent receipts prove nothing.
|
|
142
|
+
return this.finish(session, { state: "ambiguous", proofKind: "ambiguous_site_history" });
|
|
143
|
+
}
|
|
144
|
+
async finish(session, result) {
|
|
145
|
+
if (session.state === "ended")
|
|
146
|
+
return this.view(session);
|
|
147
|
+
if (session.handoff?.state === "ready")
|
|
148
|
+
await this.closeHandoff(session, "unavailable");
|
|
149
|
+
session.state = "ended";
|
|
150
|
+
session.pauseReason = undefined;
|
|
151
|
+
session.result = result;
|
|
152
|
+
await session.browser.stop().catch(() => undefined);
|
|
153
|
+
return this.view(session);
|
|
154
|
+
}
|
|
155
|
+
async closeHandoff(session, reason) {
|
|
156
|
+
if (!session.handoff || session.handoff.state === "closed")
|
|
157
|
+
return;
|
|
158
|
+
session.handoff.state = "closed";
|
|
159
|
+
session.handoff.reason = reason;
|
|
160
|
+
await this.deps.takeover?.end(session.handoff.id).catch(() => undefined);
|
|
161
|
+
}
|
|
162
|
+
closedHandoff(session, id, reason) { session.handoff = { id, expiresAt: this.now(), state: "closed", reason }; return this.view(session); }
|
|
163
|
+
page(session) { const page = session.browser.handoffPage?.(); return page && !page.isClosed() ? page : null; }
|
|
164
|
+
permitted(session, raw) { try {
|
|
165
|
+
const host = new URL(raw).hostname.toLowerCase();
|
|
166
|
+
return session.envelope.allowDomains.some((d) => domainWithin(host, d));
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return false;
|
|
170
|
+
} }
|
|
171
|
+
async blocker(page) { const state = page ? await this.pageState(page).catch(() => null) : null; return !!state && (state.login || state.captcha); }
|
|
172
|
+
async pageState(page) {
|
|
173
|
+
return page.evaluate(() => {
|
|
174
|
+
const text = (document.body?.innerText ?? "").toLowerCase();
|
|
175
|
+
const captcha = !!document.querySelector('iframe[src*="hcaptcha.com"], .h-captcha, iframe[src*="challenges.cloudflare.com"], .cf-turnstile, iframe[src*="arkoselabs"], .geetest_holder') || /confirm (that )?you('?re| are) not a robot|i'?m not a robot|verify (that )?you('?re| are) (a )?human|checking (if the site connection is secure|your browser)/.test(text);
|
|
176
|
+
// Kept in the page: site prose never crosses the runner boundary. This is deliberately
|
|
177
|
+
// narrower than a guess; anything unrecognised becomes `ambiguous`.
|
|
178
|
+
const password = !!document.querySelector("input[type=password]");
|
|
179
|
+
const otp = !!document.querySelector("input[autocomplete='one-time-code'], input[name*='otp' i], input[name*='verification' i], input[id*='otp' i], input[id*='verification' i]");
|
|
180
|
+
const emailSignIn = !!document.querySelector("form[action*='login' i] input[type=email], form[action*='sign-in' i] input[type=email], [data-auth-screen] input[type=email]");
|
|
181
|
+
return { url: location.href, login: password || otp || emailSignIn, captcha };
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
require(raw) { const id = typeof raw?.reconciliationId === "string" ? raw.reconciliationId : ""; if (!SAFE_ID.test(id))
|
|
185
|
+
throw new RunnerInputError("reconciliationId is required"); const session = this.sessions.get(id); if (!session)
|
|
186
|
+
throw new RunnerInputError("Reconciliation session not found", 404); return session; }
|
|
187
|
+
handoffRequest(raw) { const o = raw && typeof raw === "object" ? raw : {}; const handoffId = typeof o.handoffId === "string" ? o.handoffId : ""; const runId = typeof o.runId === "string" ? o.runId : ""; const applicationId = typeof o.applicationId === "string" ? o.applicationId : ""; const reconciliationId = typeof o.reconciliationId === "string" ? o.reconciliationId : ""; const browserProfile = o.browserProfile === "isolated" || o.browserProfile === "default" ? o.browserProfile : null; if (![handoffId, runId, applicationId, reconciliationId].every((v) => SAFE_ID.test(v)) || !browserProfile)
|
|
188
|
+
throw new RunnerInputError("reconciliation handoff needs opaque ids and browserProfile"); return { handoffId, runId, applicationId, reconciliationId, browserProfile }; }
|
|
189
|
+
assertHandoffBinding(session, request) { const e = session.envelope; if (e.reconciliationId !== request.reconciliationId || e.runId !== request.runId || e.applicationId !== request.applicationId || e.browserProfile !== request.browserProfile)
|
|
190
|
+
throw new RunnerInputError("The reconciliation handoff does not match this exact attempt", 403); }
|
|
191
|
+
async liveHandoff(raw) { const request = this.handoffRequest(raw); const session = this.require(request); this.assertHandoffBinding(session, request); if ((session.state !== "paused" && session.state !== "running") || session.handoff?.id !== request.handoffId || session.handoff.state !== "ready")
|
|
192
|
+
throw new RunnerInputError("The reconciliation handoff is closed", 409); if (session.handoff.expiresAt <= this.now()) {
|
|
193
|
+
await this.closeHandoff(session, "expired");
|
|
194
|
+
throw new RunnerInputError("The reconciliation handoff is closed", 409);
|
|
195
|
+
} return session; }
|
|
196
|
+
view(session) { const e = session.envelope; return { reconciliationId: e.reconciliationId, runId: e.runId, applicationId: e.applicationId, browserProfile: e.browserProfile, context: "separate_read_only_context", state: session.state, ...(session.pauseReason ? { pauseReason: session.pauseReason } : {}), ...(session.result ? { result: session.result } : {}) }; }
|
|
197
|
+
}
|
|
198
|
+
function parseEnvelope(raw) {
|
|
199
|
+
const o = raw && typeof raw === "object" ? raw : {};
|
|
200
|
+
const ids = ["reconciliationId", "runId", "instanceId", "applicationId"];
|
|
201
|
+
for (const key of ids)
|
|
202
|
+
if (typeof o[key] !== "string" || !SAFE_ID.test(o[key]))
|
|
203
|
+
throw new RunnerInputError(`Invalid reconciliation: ${key} is required`);
|
|
204
|
+
if (o.type !== "local_browser.apply.reconciliation" || (o.browserProfile !== "isolated" && o.browserProfile !== "default") || typeof o.applicationUrl !== "string" || !/^https?:\/\/[^\s/]+/i.test(o.applicationUrl))
|
|
205
|
+
throw new RunnerInputError("Invalid read-only reconciliation envelope");
|
|
206
|
+
const allowDomains = Array.isArray(o.allowDomains) ? o.allowDomains.filter((d) => typeof d === "string" && /^[a-z0-9.-]+$/i.test(d)).map((d) => d.toLowerCase()) : [];
|
|
207
|
+
if (!allowDomains.length || typeof o.jobIdentity !== "string" || !o.materialFingerprint || typeof o.materialFingerprint !== "object")
|
|
208
|
+
throw new RunnerInputError("Invalid reconciliation binding");
|
|
209
|
+
return { type: o.type, reconciliationId: o.reconciliationId, runId: o.runId, instanceId: o.instanceId, applicationId: o.applicationId, browserProfile: o.browserProfile, applicationUrl: o.applicationUrl, allowDomains, jobIdentity: o.jobIdentity, materialFingerprint: o.materialFingerprint };
|
|
210
|
+
}
|
|
@@ -17,8 +17,11 @@ export class LocalApplyHandoffRuntime {
|
|
|
17
17
|
throw new RunnerInputError("A handoff already exists for this application run", 409);
|
|
18
18
|
if (!this.host.takeover)
|
|
19
19
|
return this.closed(run, request.handoffId, "unavailable");
|
|
20
|
+
const page = run.browser?.handoffPage?.();
|
|
21
|
+
if (!page || page.isClosed())
|
|
22
|
+
return this.closed(run, request.handoffId, "page_lost");
|
|
20
23
|
try {
|
|
21
|
-
await this.host.takeover.open(request);
|
|
24
|
+
await this.host.takeover.open(request, page);
|
|
22
25
|
}
|
|
23
26
|
catch {
|
|
24
27
|
return this.closed(run, request.handoffId, "profile_unavailable");
|
|
@@ -5,14 +5,12 @@ export function isLocalApplyTakeover(id) { return id.startsWith(prefix); }
|
|
|
5
5
|
export function createLocalApplyTakeoverAdapter(deps) {
|
|
6
6
|
const key = (handoffId) => `${prefix}${handoffId}`;
|
|
7
7
|
return {
|
|
8
|
-
async open(request) {
|
|
9
|
-
if (request.browserProfile !== "default")
|
|
10
|
-
throw new RunnerInputError("This browser profile has no live handoff page", 409);
|
|
8
|
+
async open(request, page) {
|
|
11
9
|
const id = key(request.handoffId);
|
|
12
10
|
if (deps.get(id))
|
|
13
11
|
throw new RunnerInputError("A local application handoff already exists", 409);
|
|
14
|
-
// Do not create
|
|
15
|
-
|
|
12
|
+
// Do not create or substitute a page here. The runtime supplied the page retained by
|
|
13
|
+
// this exact run/profile; a destroyed page must remain a lost handoff, never look ready.
|
|
16
14
|
if (!page || page.isClosed())
|
|
17
15
|
throw new RunnerInputError("The local application page is no longer available", 409);
|
|
18
16
|
deps.set(id, page);
|
|
@@ -17,7 +17,9 @@ import { LOCAL_BROWSER_TASK_TYPE } from "./local-browser/contract.js";
|
|
|
17
17
|
import { LocalArtifactRuntime } from "./local-artifact/runtime.js";
|
|
18
18
|
import { LOCAL_ARTIFACT_TASK_TYPE } from "./local-artifact/contract.js";
|
|
19
19
|
import { LocalApplyRuntime } from "./local-apply/runtime.js";
|
|
20
|
+
import { LocalApplyReconciliationRuntime } from "./local-apply/reconciliation-runtime.js";
|
|
20
21
|
import { LOCAL_APPLY_TASK_TYPE } from "./local-apply/contract.js";
|
|
22
|
+
import { createRunnerBrowserFactories } from "./local-apply/live-run-browser.js";
|
|
21
23
|
import { createLocalApplyTakeoverAdapter, isLocalApplyTakeover } from "./local-apply/takeover-adapter.js";
|
|
22
24
|
import { WORKFLOW_DRIVEN_TASKS } from "./task-types.js";
|
|
23
25
|
/** True for a plain object. */
|
|
@@ -86,6 +88,8 @@ export class LocalRunner {
|
|
|
86
88
|
localArtifact;
|
|
87
89
|
/** Local application execution (#957): a signed-in CLI filling one application through the apply bridge. */
|
|
88
90
|
localApply;
|
|
91
|
+
/** Browser-only, no-bridge reconciliation for an ended uncertain apply attempt. */
|
|
92
|
+
localApplyReconciliation;
|
|
89
93
|
/** This runner's own local URL, once the server listens — the bridge forwarder calls back to it. */
|
|
90
94
|
selfUrl = null;
|
|
91
95
|
/** Live human-takeover sessions, keyed by task id (the page is kept alive). */
|
|
@@ -95,23 +99,17 @@ export class LocalRunner {
|
|
|
95
99
|
mkdirSync(config.dataDir, { recursive: true });
|
|
96
100
|
this.store = new RunnerStore(config.dataDir);
|
|
97
101
|
this.coding = new CodingRuntime(join(config.dataDir, "repos"));
|
|
98
|
-
|
|
99
|
-
// `isolated` is a fresh profile inside the run's folder, removed with it.
|
|
100
|
-
const browserFor = async (profile, runDir) => {
|
|
101
|
-
if (profile === "default")
|
|
102
|
-
return { tools: await this.getMcp(), stop: async () => undefined };
|
|
103
|
-
const mcp = new McpRuntime();
|
|
104
|
-
await mcp.start({ userDataDir: join(runDir, "profile"), headless: config.headless });
|
|
105
|
-
return { tools: mcp, stop: () => mcp.stop() };
|
|
106
|
-
};
|
|
102
|
+
const { browserFor, applyBrowserFor } = createRunnerBrowserFactories({ headless: config.headless, defaultTools: () => this.getMcp(), sharedPage: () => this.activePage });
|
|
107
103
|
this.localBrowser = new LocalBrowserRuntime({ dataDir: config.dataDir, selfUrl: () => this.selfUrl, browserFor });
|
|
108
104
|
this.localArtifact = new LocalArtifactRuntime({ dataDir: config.dataDir });
|
|
105
|
+
const localApplyTakeover = createLocalApplyTakeoverAdapter({ get: (id) => this.takeovers.get(id), set: (id, page) => this.takeovers.set(id, { page, reason: "local_apply", humanDone: false }), frame: (id) => this.takeoverFrame(id), input: (id, input) => this.takeoverInput(id, input), end: (id) => this.endTakeover(id) });
|
|
109
106
|
this.localApply = new LocalApplyRuntime({
|
|
110
107
|
dataDir: config.dataDir,
|
|
111
108
|
selfUrl: () => this.selfUrl,
|
|
112
|
-
browserFor,
|
|
113
|
-
takeover:
|
|
109
|
+
browserFor: applyBrowserFor,
|
|
110
|
+
takeover: localApplyTakeover,
|
|
114
111
|
});
|
|
112
|
+
this.localApplyReconciliation = new LocalApplyReconciliationRuntime({ dataDir: config.dataDir, browserFor: applyBrowserFor, takeover: localApplyTakeover });
|
|
115
113
|
// Tasks paused/running on a previous process are orphaned now — their
|
|
116
114
|
// pages and takeover sessions are gone. Fail them so the board is clean.
|
|
117
115
|
const expired = this.store.expireInFlightTasks();
|
|
@@ -129,7 +127,7 @@ export class LocalRunner {
|
|
|
129
127
|
*/
|
|
130
128
|
liveWork() {
|
|
131
129
|
const coding = this.coding.liveWork();
|
|
132
|
-
const local = [...this.localBrowser.liveWork(), ...this.localArtifact.liveWork(), ...this.localApply.liveWork()];
|
|
130
|
+
const local = [...this.localBrowser.liveWork(), ...this.localArtifact.liveWork(), ...this.localApply.liveWork(), ...this.localApplyReconciliation.liveWork()];
|
|
133
131
|
return { codingTurns: coding.length, localRuns: local.length, detail: [...coding, ...local].slice(0, 10) };
|
|
134
132
|
}
|
|
135
133
|
capabilities() {
|
|
@@ -290,6 +288,7 @@ export class LocalRunner {
|
|
|
290
288
|
this.localBrowser.closeAll();
|
|
291
289
|
this.localArtifact.closeAll();
|
|
292
290
|
this.localApply.closeAll();
|
|
291
|
+
this.localApplyReconciliation.closeAll();
|
|
293
292
|
try {
|
|
294
293
|
this.coding.closeAll();
|
|
295
294
|
}
|
|
@@ -5,6 +5,7 @@ import { probeGitSshIdentity } from "./coding/repo.js";
|
|
|
5
5
|
import { checkEngine } from "./coding/engine-check.js";
|
|
6
6
|
import { listGithubOrgs, listGithubRepos, searchGithubRepos, getGithubRepoDetail, getGithubCredentialScope } from "./coding/github-browse.js";
|
|
7
7
|
import { routeLocalApplyHandoff } from "./local-apply/handoff-routes.js";
|
|
8
|
+
import { routeLocalApplyReconciliation } from "./local-apply/reconciliation-routes.js";
|
|
8
9
|
export function createRunnerServer(runner) {
|
|
9
10
|
return createServer(async (req, res) => {
|
|
10
11
|
try {
|
|
@@ -239,6 +240,9 @@ async function route(runner, req, res) {
|
|
|
239
240
|
const handoff = await routeLocalApplyHandoff(runner.localApply, req.method, path, () => readJson(req));
|
|
240
241
|
if (handoff)
|
|
241
242
|
return json(res, 200, handoff);
|
|
243
|
+
const reconciliation = await routeLocalApplyReconciliation(runner.localApplyReconciliation, req.method, path, () => readJson(req));
|
|
244
|
+
if (reconciliation !== undefined)
|
|
245
|
+
return json(res, 200, reconciliation);
|
|
242
246
|
if (req.method === "POST" && path === "/local-apply/resume") {
|
|
243
247
|
return json(res, 200, runner.localApply.resume(await readJson(req)));
|
|
244
248
|
}
|