stageflow 0.27.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/builtin-skills/browser/SKILL.md +42 -0
- package/dist/agent/claudeAdapter.js +2 -2
- package/dist/agent/cursorProvider.d.ts +2 -0
- package/dist/agent/cursorProvider.js +30 -5
- package/dist/agent/fakeAgent.d.ts +1 -0
- package/dist/agent/fakeAgent.js +13 -0
- package/dist/agent/piAdapter.d.ts +3 -24
- package/dist/agent/piAdapter.js +49 -47
- package/dist/agent/piSessionFactory.d.ts +38 -0
- package/dist/agent/piSessionFactory.js +60 -0
- package/dist/agent/port.d.ts +2 -0
- package/dist/agent/streamLogRedact.d.ts +6 -0
- package/dist/agent/streamLogRedact.js +23 -1
- package/dist/browser/anchor.d.ts +27 -0
- package/dist/browser/anchor.js +127 -0
- package/dist/browser/auditSink.d.ts +38 -0
- package/dist/browser/auditSink.js +34 -0
- package/dist/browser/browserHost.d.ts +86 -0
- package/dist/browser/browserHost.js +7 -0
- package/dist/browser/browserSweep.d.ts +14 -0
- package/dist/browser/browserSweep.js +54 -0
- package/dist/browser/browserTeardown.d.ts +56 -0
- package/dist/browser/browserTeardown.js +235 -0
- package/dist/browser/fakeRemoteBrowserHost.d.ts +2 -0
- package/dist/browser/fakeRemoteBrowserHost.js +34 -0
- package/dist/browser/gateHandoff.d.ts +25 -0
- package/dist/browser/gateHandoff.js +83 -0
- package/dist/browser/humanLogin.d.ts +18 -0
- package/dist/browser/humanLogin.js +34 -0
- package/dist/browser/keyProvider.d.ts +10 -0
- package/dist/browser/keyProvider.js +39 -0
- package/dist/browser/localBrowserHost.d.ts +10 -0
- package/dist/browser/localBrowserHost.js +152 -0
- package/dist/browser/localProfileLock.d.ts +2 -0
- package/dist/browser/localProfileLock.js +218 -0
- package/dist/browser/localProfileStore.d.ts +5 -0
- package/dist/browser/localProfileStore.js +91 -0
- package/dist/browser/loginCheck.d.ts +53 -0
- package/dist/browser/loginCheck.js +147 -0
- package/dist/browser/memoryProfileLock.d.ts +2 -0
- package/dist/browser/memoryProfileLock.js +53 -0
- package/dist/browser/memoryProfileStore.d.ts +2 -0
- package/dist/browser/memoryProfileStore.js +31 -0
- package/dist/browser/navigationAudit.d.ts +16 -0
- package/dist/browser/navigationAudit.js +53 -0
- package/dist/browser/persistedEnv.d.ts +3 -0
- package/dist/browser/persistedEnv.js +15 -0
- package/dist/browser/profileLock.d.ts +40 -0
- package/dist/browser/profileLock.js +10 -0
- package/dist/browser/profileStore.d.ts +23 -0
- package/dist/browser/profileStore.js +39 -0
- package/dist/browser/runLiveness.d.ts +13 -0
- package/dist/browser/runLiveness.js +39 -0
- package/dist/browser/sitePolicy.d.ts +10 -0
- package/dist/browser/sitePolicy.js +61 -0
- package/dist/browser/stageBrowserEnv.d.ts +22 -0
- package/dist/browser/stageBrowserEnv.js +147 -0
- package/dist/browser/stageProfileLock.d.ts +17 -0
- package/dist/browser/stageProfileLock.js +39 -0
- package/dist/cli/browserCommand.d.ts +44 -0
- package/dist/cli/browserCommand.js +472 -0
- package/dist/cli/hostClient.d.ts +20 -0
- package/dist/cli/hostClient.js +36 -2
- package/dist/cli/initCommand.d.ts +1 -0
- package/dist/cli/initCommand.js +23 -7
- package/dist/cli/initTemplates.d.ts +5 -2
- package/dist/cli/initTemplates.js +25 -2
- package/dist/cli/runsCommand.js +6 -2
- package/dist/cli/triggerCommand.d.ts +12 -0
- package/dist/cli/triggerCommand.js +183 -0
- package/dist/cli/validateCommand.d.ts +1 -1
- package/dist/cli/validateCommand.js +23 -6
- package/dist/cli.js +31 -2
- package/dist/config/browseCatalog.d.ts +1 -0
- package/dist/config/browseCatalog.js +5 -4
- package/dist/config/builtinSkills.d.ts +4 -0
- package/dist/config/builtinSkills.js +23 -0
- package/dist/config/catalogRelativePath.d.ts +1 -0
- package/dist/config/catalogRelativePath.js +10 -2
- package/dist/config/createTrigger.d.ts +27 -0
- package/dist/config/createTrigger.js +232 -0
- package/dist/config/draftPackage.d.ts +117 -0
- package/dist/config/draftPackage.js +550 -0
- package/dist/config/ensureCatalogScanRoot.d.ts +16 -0
- package/dist/config/ensureCatalogScanRoot.js +73 -0
- package/dist/config/hostConfig.d.ts +2 -0
- package/dist/config/hostConfig.js +27 -0
- package/dist/config/listSkills.d.ts +2 -2
- package/dist/config/listSkills.js +17 -3
- package/dist/config/loadOutcome.d.ts +6 -0
- package/dist/config/loadPipeline.js +16 -0
- package/dist/config/loadStage.js +7 -0
- package/dist/config/loadStageflowManifest.js +25 -0
- package/dist/config/loadTrigger.d.ts +9 -0
- package/dist/config/loadTrigger.js +120 -0
- package/dist/config/multiProjectCatalog.d.ts +2 -0
- package/dist/config/multiProjectCatalog.js +26 -18
- package/dist/config/normalizePipelineStageEntry.js +8 -1
- package/dist/config/parseCompletionContract.js +6 -0
- package/dist/config/pipelineStageKeys.js +1 -0
- package/dist/config/publishDraftPackage.d.ts +7 -0
- package/dist/config/publishDraftPackage.js +25 -0
- package/dist/config/resolveCatalogContext.js +1 -1
- package/dist/config/resolvePipelineDag.js +1 -1
- package/dist/config/scanCatalogPaths.d.ts +1 -1
- package/dist/config/scanCatalogPaths.js +6 -2
- package/dist/config/stageBrowser.d.ts +12 -0
- package/dist/config/stageBrowser.js +118 -0
- package/dist/config/triggerCatalog.d.ts +10 -0
- package/dist/config/triggerCatalog.js +37 -0
- package/dist/config/validateCatalog.d.ts +13 -3
- package/dist/config/validateCatalog.js +83 -2
- package/dist/config/validateCompletionContract.js +8 -0
- package/dist/envelope/preEmitChecks.d.ts +1 -1
- package/dist/envelope/preEmitChecks.js +8 -0
- package/dist/logging/redact.d.ts +4 -0
- package/dist/logging/redact.js +17 -0
- package/dist/mcp/catalogTools.js +43 -8
- package/dist/mcp/controlTools.js +3 -1
- package/dist/mcp/deps.d.ts +2 -0
- package/dist/mcp/server.js +1 -0
- package/dist/mcp/tools.js +2 -0
- package/dist/mcp/triggerTools.d.ts +3 -0
- package/dist/mcp/triggerTools.js +176 -0
- package/dist/operatorAgent/docsRetrieval.d.ts +40 -0
- package/dist/operatorAgent/docsRetrieval.js +269 -0
- package/dist/operatorAgent/draftContext.d.ts +16 -0
- package/dist/operatorAgent/draftContext.js +68 -0
- package/dist/operatorAgent/fakeHost.d.ts +39 -0
- package/dist/operatorAgent/fakeHost.js +92 -0
- package/dist/operatorAgent/host.d.ts +40 -0
- package/dist/operatorAgent/host.js +179 -0
- package/dist/operatorAgent/index.d.ts +9 -0
- package/dist/operatorAgent/index.js +8 -0
- package/dist/operatorAgent/piModel.d.ts +80 -0
- package/dist/operatorAgent/piModel.js +528 -0
- package/dist/operatorAgent/profiles/workshopAuthor.d.ts +23 -0
- package/dist/operatorAgent/profiles/workshopAuthor.js +338 -0
- package/dist/operatorAgent/profiles/workshopAuthorPlaybook.d.ts +6 -0
- package/dist/operatorAgent/profiles/workshopAuthorPlaybook.js +103 -0
- package/dist/operatorAgent/profiles/workshopAuthorTools.d.ts +57 -0
- package/dist/operatorAgent/profiles/workshopAuthorTools.js +623 -0
- package/dist/operatorAgent/proposals.d.ts +32 -0
- package/dist/operatorAgent/proposals.js +144 -0
- package/dist/operatorAgent/types.d.ts +128 -0
- package/dist/operatorAgent/types.js +1 -0
- package/dist/package-meta.d.ts +1 -1
- package/dist/package-meta.js +1 -1
- package/dist/project/findManifestRoot.d.ts +3 -0
- package/dist/project/findManifestRoot.js +35 -0
- package/dist/project/resolveStageflowContext.d.ts +7 -0
- package/dist/project/resolveStageflowContext.js +44 -28
- package/dist/runstore/assertTmpdir.js +6 -2
- package/dist/runstore/port.d.ts +28 -0
- package/dist/runstore/sqlite/SqliteRunStore.d.ts +8 -1
- package/dist/runstore/sqlite/SqliteRunStore.js +90 -0
- package/dist/runstore/sqlite/migrations/008-triggers-table.d.ts +7 -0
- package/dist/runstore/sqlite/migrations/008-triggers-table.js +19 -0
- package/dist/runstore/sqlite/migrations/009-trigger-next-run.d.ts +7 -0
- package/dist/runstore/sqlite/migrations/009-trigger-next-run.js +19 -0
- package/dist/runstore/sqlite/migrations/010-trigger-adapter-state.d.ts +7 -0
- package/dist/runstore/sqlite/migrations/010-trigger-adapter-state.js +17 -0
- package/dist/runstore/sqlite/migrations/index.js +6 -0
- package/dist/runtime/answerResume.d.ts +1 -0
- package/dist/runtime/answerResume.js +3 -1
- package/dist/runtime/completionCheckRunner.d.ts +12 -1
- package/dist/runtime/completionCheckRunner.js +22 -0
- package/dist/runtime/credentialBinding.d.ts +2 -0
- package/dist/runtime/credentialBinding.js +3 -0
- package/dist/runtime/emailSource.d.ts +72 -0
- package/dist/runtime/emailSource.js +304 -0
- package/dist/runtime/githubPollSource.d.ts +86 -0
- package/dist/runtime/githubPollSource.js +284 -0
- package/dist/runtime/pipelineRunner.d.ts +4 -0
- package/dist/runtime/pipelineRunner.js +3 -0
- package/dist/runtime/pipelineScheduler.d.ts +2 -0
- package/dist/runtime/pipelineScheduler.js +85 -1
- package/dist/runtime/repositoryMaterialize.d.ts +1 -1
- package/dist/runtime/runManager.d.ts +11 -0
- package/dist/runtime/runManager.js +149 -1
- package/dist/runtime/runRetryCoordinator.d.ts +2 -0
- package/dist/runtime/runRetryCoordinator.js +1 -0
- package/dist/runtime/scheduleSource.d.ts +38 -0
- package/dist/runtime/scheduleSource.js +119 -0
- package/dist/runtime/settingsFile.d.ts +5 -0
- package/dist/runtime/settingsFile.js +61 -54
- package/dist/runtime/stageAttemptBootstrap.js +55 -3
- package/dist/runtime/stageEnvironment.js +1 -0
- package/dist/runtime/stageHitl.d.ts +1 -0
- package/dist/runtime/stageHitl.js +5 -1
- package/dist/runtime/stageProcessLauncher.d.ts +2 -0
- package/dist/runtime/stageProcessLauncher.js +44 -5
- package/dist/runtime/stageRunner.d.ts +5 -0
- package/dist/runtime/stageRunner.js +30 -6
- package/dist/runtime/stageStreamLog.d.ts +1 -0
- package/dist/runtime/stageStreamLog.js +2 -1
- package/dist/runtime/triggerCatalog.d.ts +6 -0
- package/dist/runtime/triggerCatalog.js +24 -0
- package/dist/runtime/triggerEventMatch.d.ts +2 -0
- package/dist/runtime/triggerEventMatch.js +6 -0
- package/dist/runtime/triggerPort.d.ts +14 -0
- package/dist/runtime/triggerPort.js +1 -0
- package/dist/runtime/triggerRunner.d.ts +17 -0
- package/dist/runtime/triggerRunner.js +89 -0
- package/dist/runtime/verifiedStageExecution.d.ts +7 -0
- package/dist/runtime/verifiedStageExecution.js +14 -0
- package/dist/runtime/webhookSignature.d.ts +1 -0
- package/dist/runtime/webhookSignature.js +12 -0
- package/dist/server/bootstrap.d.ts +6 -0
- package/dist/server/bootstrap.js +64 -0
- package/dist/server/controlToken.js +13 -0
- package/dist/server/createHttpHost.js +3 -0
- package/dist/server/healthSurfaces.js +31 -6
- package/dist/server/http.d.ts +15 -0
- package/dist/server/http.js +1251 -74
- package/dist/server/requestAuthContext.d.ts +1 -1
- package/dist/server/triggerRoutes.d.ts +15 -0
- package/dist/server/triggerRoutes.js +268 -0
- package/dist/tools/askOperator.d.ts +3 -2
- package/dist/types/completion.d.ts +3 -0
- package/dist/types/pipeline.d.ts +3 -1
- package/dist/types/preEmitCheck.d.ts +7 -0
- package/dist/types/stage.d.ts +14 -0
- package/dist/types/stageflowManifest.d.ts +3 -0
- package/dist/types/trigger.d.ts +18 -0
- package/dist/types/trigger.js +1 -0
- package/dist/ui/assets/{index-CSsDued1.css → index-Bv4p1aPK.css} +1 -1
- package/dist/ui/assets/index-CntB4bZR.js +133 -0
- package/dist/ui/index.html +2 -2
- package/dist/workshop/autosave.d.ts +58 -0
- package/dist/workshop/autosave.js +222 -0
- package/dist/workshop/buildStore.d.ts +79 -0
- package/dist/workshop/buildStore.js +326 -0
- package/dist/workshop/chatTurn.d.ts +232 -0
- package/dist/workshop/chatTurn.js +475 -0
- package/dist/workshop/modelSettings.d.ts +18 -0
- package/dist/workshop/modelSettings.js +44 -0
- package/dist/workshop/sessionStore.d.ts +61 -0
- package/dist/workshop/sessionStore.js +254 -0
- package/dist/workshop/workshopPin.d.ts +72 -0
- package/dist/workshop/workshopPin.js +193 -0
- package/examples/README.md +2 -1
- package/examples/browser-session/README.md +77 -0
- package/examples/browser-session/browser-session.pipeline.yaml +50 -0
- package/examples/browser-session/browser-session.task.yaml +6 -0
- package/examples/browser-session/check-login.yaml +22 -0
- package/examples/browser-session/fixture-server.mjs +64 -0
- package/examples/browser-session/human-login.yaml +31 -0
- package/examples/browser-session/merge.yaml +36 -0
- package/examples/browser-session/work-a.yaml +41 -0
- package/examples/browser-session/work-b.yaml +41 -0
- package/examples/stage-mcp/README.md +3 -2
- package/examples/stageflow.yaml +51 -0
- package/package.json +5 -1
- package/skills/stageflow/references/control-surface.md +3 -1
- package/skills/stageflow-author/references/stage-prompt-template.md +4 -4
- package/skills/stageflow-run/SKILL.md +2 -0
- package/skills/stageflow-run/references/task-and-pipeline-selection.md +3 -1
- package/skills/stageflow-setup/SKILL.md +11 -0
- package/dist/ui/assets/index-B6hlSXXn.js +0 -120
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { defaultBrowserRunner, writeSessionOwner } from "./browserTeardown.js";
|
|
4
|
+
export const BROWSER_ANCHOR_FILENAME = "anchor.json";
|
|
5
|
+
const ANCHOR_LOCK_DIRNAME = "anchor.lock";
|
|
6
|
+
const LOCK_POLL_MS = 25;
|
|
7
|
+
const LOCK_WAIT_MS = 120_000;
|
|
8
|
+
export function anchorDir(runDir, profileName) {
|
|
9
|
+
return path.join(runDir, "browser", profileName);
|
|
10
|
+
}
|
|
11
|
+
export async function readPersistedAnchor(runDir, profileName) {
|
|
12
|
+
try {
|
|
13
|
+
const parsed = JSON.parse(await readFile(path.join(anchorDir(runDir, profileName), BROWSER_ANCHOR_FILENAME), "utf8"));
|
|
14
|
+
if (typeof parsed.cdpAddress === "string" &&
|
|
15
|
+
parsed.anchorEnv !== undefined &&
|
|
16
|
+
typeof parsed.anchorEnv === "object") {
|
|
17
|
+
return parsed;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
if (err.code !== "ENOENT")
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
function pidAlive(pid) {
|
|
27
|
+
try {
|
|
28
|
+
process.kill(pid, 0);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
return err.code === "EPERM";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Cross-process mutex: atomic mkdir, stale when the recorded pid is gone. */
|
|
36
|
+
async function withFileLock(dir, fn) {
|
|
37
|
+
await mkdir(dir, { recursive: true });
|
|
38
|
+
const lock = path.join(dir, ANCHOR_LOCK_DIRNAME);
|
|
39
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
40
|
+
for (;;) {
|
|
41
|
+
try {
|
|
42
|
+
await mkdir(lock);
|
|
43
|
+
await writeFile(path.join(lock, "pid"), String(process.pid));
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
if (err.code !== "EEXIST")
|
|
48
|
+
throw err;
|
|
49
|
+
let holder = 0;
|
|
50
|
+
try {
|
|
51
|
+
holder = Number.parseInt(await readFile(path.join(lock, "pid"), "utf8"), 10);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// holder is between mkdir and pid write
|
|
55
|
+
}
|
|
56
|
+
if (holder > 0 && holder !== process.pid && !pidAlive(holder)) {
|
|
57
|
+
await rm(lock, { recursive: true, force: true });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (Date.now() >= deadline)
|
|
61
|
+
throw new Error("timed out waiting for the shared browser lock");
|
|
62
|
+
await new Promise((r) => setTimeout(r, LOCK_POLL_MS));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return await fn();
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
await rm(lock, { recursive: true, force: true }).catch(() => undefined);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const inflight = new Map();
|
|
73
|
+
/**
|
|
74
|
+
* One shared browser per (run, profile). Concurrent callers in this process
|
|
75
|
+
* share one promise; other processes serialize on a lock dir. The anchor and
|
|
76
|
+
* its CDP address are persisted so stages, resume workers and the sweep reuse
|
|
77
|
+
* them; a dead anchor is restarted and the address refreshed.
|
|
78
|
+
*/
|
|
79
|
+
export function ensureRunProfileBrowser(support, input) {
|
|
80
|
+
const key = `${input.runDir}\0${input.profile.key.name}\0${input.humanLogin === true ? "h" : ""}`;
|
|
81
|
+
const existing = inflight.get(key);
|
|
82
|
+
if (existing !== undefined)
|
|
83
|
+
return existing;
|
|
84
|
+
const promise = withFileLock(anchorDir(input.runDir, input.profile.key.name), async () => {
|
|
85
|
+
const stored = await readPersistedAnchor(input.runDir, input.profile.key.name);
|
|
86
|
+
const result = await support.host.ensureProfileBrowser({
|
|
87
|
+
runId: input.runId,
|
|
88
|
+
browser: input.browser,
|
|
89
|
+
profile: input.profile,
|
|
90
|
+
runner: support.runner ?? defaultBrowserRunner,
|
|
91
|
+
...(input.humanLogin === true ? { humanLogin: true } : {}),
|
|
92
|
+
...(stored !== undefined
|
|
93
|
+
? { previous: { cdpAddress: stored.cdpAddress, anchorEnv: stored.anchorEnv } }
|
|
94
|
+
: {}),
|
|
95
|
+
});
|
|
96
|
+
const changed = stored === undefined ||
|
|
97
|
+
stored.cdpAddress !== result.cdpAddress ||
|
|
98
|
+
JSON.stringify(stored.anchorEnv) !== JSON.stringify(result.anchorEnv);
|
|
99
|
+
const persisted = {
|
|
100
|
+
cdpAddress: result.cdpAddress,
|
|
101
|
+
anchorEnv: result.anchorEnv,
|
|
102
|
+
runId: input.runId,
|
|
103
|
+
profile: input.profile.key.name,
|
|
104
|
+
restarts: (stored?.restarts ?? 0) + (stored !== undefined && changed ? 1 : 0),
|
|
105
|
+
};
|
|
106
|
+
if (changed) {
|
|
107
|
+
const dir = anchorDir(input.runDir, input.profile.key.name);
|
|
108
|
+
const file = path.join(dir, BROWSER_ANCHOR_FILENAME);
|
|
109
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
110
|
+
await writeFile(tmp, `${JSON.stringify(persisted, null, 2)}\n`, { mode: 0o600 });
|
|
111
|
+
await rename(tmp, file);
|
|
112
|
+
await writeSessionOwner({
|
|
113
|
+
runId: input.runId,
|
|
114
|
+
stageId: `anchor:${input.profile.key.name}`,
|
|
115
|
+
runDir: input.runDir,
|
|
116
|
+
env: result.anchorEnv,
|
|
117
|
+
anchor: true,
|
|
118
|
+
profile: input.profile.key.name,
|
|
119
|
+
}).catch(() => undefined);
|
|
120
|
+
}
|
|
121
|
+
return { ...result, restarted: stored !== undefined && changed, persisted };
|
|
122
|
+
}).finally(() => {
|
|
123
|
+
inflight.delete(key);
|
|
124
|
+
});
|
|
125
|
+
inflight.set(key, promise);
|
|
126
|
+
return promise;
|
|
127
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Records carry names, ids and hosts only: never paths, URLs with queries, or cookie values. */
|
|
2
|
+
export type AuditRecord = {
|
|
3
|
+
event: "profile_created";
|
|
4
|
+
scope: string;
|
|
5
|
+
profile: string;
|
|
6
|
+
} | {
|
|
7
|
+
event: "profile_deleted";
|
|
8
|
+
scope: string;
|
|
9
|
+
profile: string;
|
|
10
|
+
} | {
|
|
11
|
+
event: "profile_used";
|
|
12
|
+
scope: string;
|
|
13
|
+
profile: string;
|
|
14
|
+
runId: string;
|
|
15
|
+
stageId: string;
|
|
16
|
+
} | {
|
|
17
|
+
event: "navigation_outside_allowlist";
|
|
18
|
+
runId: string;
|
|
19
|
+
stageId: string;
|
|
20
|
+
profile?: string;
|
|
21
|
+
host: string;
|
|
22
|
+
} | {
|
|
23
|
+
event: "allowlist_unverified";
|
|
24
|
+
runId: string;
|
|
25
|
+
stageId: string;
|
|
26
|
+
profile?: string;
|
|
27
|
+
reason: string;
|
|
28
|
+
};
|
|
29
|
+
export interface AuditSink {
|
|
30
|
+
record(entry: AuditRecord): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
export declare function localAuditLogPath(): string;
|
|
33
|
+
export declare function createLocalAuditSink(file?: string): AuditSink;
|
|
34
|
+
export declare function createMemoryAuditSink(): AuditSink & {
|
|
35
|
+
records: AuditRecord[];
|
|
36
|
+
};
|
|
37
|
+
/** Audit must never break a run. */
|
|
38
|
+
export declare function safeAudit(sink: AuditSink | undefined, entry: AuditRecord): Promise<void>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { appendFile, chmod, mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { globalStageflowHome } from "../project/globalHome.js";
|
|
4
|
+
export function localAuditLogPath() {
|
|
5
|
+
return path.join(globalStageflowHome(), "browser", "audit.jsonl");
|
|
6
|
+
}
|
|
7
|
+
export function createLocalAuditSink(file) {
|
|
8
|
+
return {
|
|
9
|
+
async record(entry) {
|
|
10
|
+
const target = file ?? localAuditLogPath();
|
|
11
|
+
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
12
|
+
await appendFile(target, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`, { mode: 0o600 });
|
|
13
|
+
await chmod(target, 0o600).catch(() => undefined);
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function createMemoryAuditSink() {
|
|
18
|
+
const records = [];
|
|
19
|
+
return {
|
|
20
|
+
records,
|
|
21
|
+
async record(entry) {
|
|
22
|
+
records.push(entry);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** Audit must never break a run. */
|
|
27
|
+
export async function safeAudit(sink, entry) {
|
|
28
|
+
try {
|
|
29
|
+
await (sink ?? createLocalAuditSink()).record(entry);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// best-effort
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { StageBrowserConfig } from "../types/stage.js";
|
|
2
|
+
import type { ProfileLock } from "./profileLock.js";
|
|
3
|
+
import type { AuditSink } from "./auditSink.js";
|
|
4
|
+
import type { ProfileHandle, ProfileStore } from "./profileStore.js";
|
|
5
|
+
export type BrowserEnv = Record<string, string>;
|
|
6
|
+
export type BrowserStageRequest = {
|
|
7
|
+
runId: string;
|
|
8
|
+
stageId: string;
|
|
9
|
+
browser: StageBrowserConfig;
|
|
10
|
+
profile?: ProfileHandle;
|
|
11
|
+
/** A human must see and use this browser; headless fallback does not apply. */
|
|
12
|
+
humanLogin?: boolean;
|
|
13
|
+
/** Required for a stage with a profile: the shared browser's CDP address. */
|
|
14
|
+
cdpAddress?: string;
|
|
15
|
+
};
|
|
16
|
+
/** The shared browser of one (run, profile): its CDP address and the env that owns it. */
|
|
17
|
+
export type ProfileBrowser = {
|
|
18
|
+
cdpAddress: string;
|
|
19
|
+
anchorEnv: BrowserEnv;
|
|
20
|
+
};
|
|
21
|
+
export type ProfileBrowserRequest = {
|
|
22
|
+
runId: string;
|
|
23
|
+
browser: StageBrowserConfig;
|
|
24
|
+
profile: ProfileHandle;
|
|
25
|
+
humanLogin?: boolean;
|
|
26
|
+
runner: BrowserRunner;
|
|
27
|
+
/** Persisted anchor from an earlier call; reused when still alive, else restarted. */
|
|
28
|
+
previous?: ProfileBrowser;
|
|
29
|
+
};
|
|
30
|
+
export type ProfileBrowserResult = ProfileBrowser & {
|
|
31
|
+
/** True when a previous anchor existed but its address changed or it was replaced. */
|
|
32
|
+
restarted: boolean;
|
|
33
|
+
};
|
|
34
|
+
export interface BrowserHost {
|
|
35
|
+
/**
|
|
36
|
+
* Starts or reuses the one browser for (run, profile) and returns its CDP
|
|
37
|
+
* address. Callers serialize per (run, profile) and persist the result.
|
|
38
|
+
*/
|
|
39
|
+
ensureProfileBrowser(request: ProfileBrowserRequest): Promise<ProfileBrowserResult>;
|
|
40
|
+
/** Env of one stage's agent-browser session; with a profile it attaches to `cdpAddress` in its own tab. */
|
|
41
|
+
stageEnv(request: BrowserStageRequest): Promise<BrowserEnv>;
|
|
42
|
+
/** Env of the session that owns the profile's browser (anchor, or `sf browser login`). */
|
|
43
|
+
profileBrowserEnv(request: Omit<BrowserStageRequest, "stageId" | "cdpAddress"> & {
|
|
44
|
+
profile: ProfileHandle;
|
|
45
|
+
}): Promise<BrowserEnv>;
|
|
46
|
+
}
|
|
47
|
+
/** Runs `agent-browser <args>` with exactly `env` (plus PATH). Injectable for tests. */
|
|
48
|
+
export type BrowserRunner = (args: string[], env: BrowserEnv,
|
|
49
|
+
/** Per-call limit; the default runner allows 20 s. */
|
|
50
|
+
options?: {
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
}) => Promise<{
|
|
53
|
+
code: number | null;
|
|
54
|
+
stdout?: string;
|
|
55
|
+
}>;
|
|
56
|
+
export type StageBrowserSupport = {
|
|
57
|
+
host: BrowserHost;
|
|
58
|
+
profiles: ProfileStore;
|
|
59
|
+
runner?: BrowserRunner;
|
|
60
|
+
/** Profile lock; defaults to the local lock-file implementation. */
|
|
61
|
+
locks?: ProfileLock;
|
|
62
|
+
/** Poll interval while a stage waits for a busy profile. */
|
|
63
|
+
lockPollMs?: number;
|
|
64
|
+
/** Upper bound for waiting on the daemon to exit after `close`. */
|
|
65
|
+
closeWaitMs?: number;
|
|
66
|
+
/** Login-check bounds; defaults suit real browsers. */
|
|
67
|
+
loginCheck?: {
|
|
68
|
+
waitMs?: number;
|
|
69
|
+
settleMs?: number;
|
|
70
|
+
};
|
|
71
|
+
/** Root holding per-session socket dirs; defaults to the local host's root. */
|
|
72
|
+
socketRoot?: string;
|
|
73
|
+
/** Defaults to the local audit log in the Stageflow home. */
|
|
74
|
+
audit?: AuditSink;
|
|
75
|
+
/** Host-blocked sites; defaults to `browser.blocked_sites` from Host config. */
|
|
76
|
+
blockedSites?: readonly string[];
|
|
77
|
+
/** Screen detection for human login stages; defaults to the real Host. */
|
|
78
|
+
display?: () => {
|
|
79
|
+
hasDisplay: boolean;
|
|
80
|
+
docker: boolean;
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
export declare const BROWSER_ENV_PREFIX = "AGENT_BROWSER_";
|
|
84
|
+
export declare function shortHash(value: string, length: number): string;
|
|
85
|
+
export declare const BROWSER_ENV_FILENAME = "browser-env.json";
|
|
86
|
+
export declare const BROWSER_POLICY_FILENAME = "browser-policy.json";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export const BROWSER_ENV_PREFIX = "AGENT_BROWSER_";
|
|
3
|
+
export function shortHash(value, length) {
|
|
4
|
+
return createHash("sha256").update(value).digest("hex").slice(0, length);
|
|
5
|
+
}
|
|
6
|
+
export const BROWSER_ENV_FILENAME = "browser-env.json";
|
|
7
|
+
export const BROWSER_POLICY_FILENAME = "browser-policy.json";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { BrowserRunner } from "./browserHost.js";
|
|
2
|
+
export type BrowserSweepOptions = {
|
|
3
|
+
isRunLive: (runId: string) => Promise<boolean>;
|
|
4
|
+
socketRoot?: string;
|
|
5
|
+
runner?: BrowserRunner;
|
|
6
|
+
closeWaitMs?: number;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* At Host start: close sessions whose run is gone or terminal, clean leftover
|
|
10
|
+
* files, and leave sessions of live or waiting runs alone.
|
|
11
|
+
*/
|
|
12
|
+
export declare function sweepOrphanBrowserSessions(options: BrowserSweepOptions): Promise<{
|
|
13
|
+
closed: string[];
|
|
14
|
+
}>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { BROWSER_OWNER_FILENAME, closeBrowserSession, } from "./browserTeardown.js";
|
|
4
|
+
import { defaultSocketRoot } from "./localBrowserHost.js";
|
|
5
|
+
import { rmdir } from "node:fs/promises";
|
|
6
|
+
async function readOwner(dir) {
|
|
7
|
+
try {
|
|
8
|
+
const parsed = JSON.parse(await readFile(path.join(dir, BROWSER_OWNER_FILENAME), "utf8"));
|
|
9
|
+
if (typeof parsed.runId === "string" && parsed.env?.AGENT_BROWSER_SESSION) {
|
|
10
|
+
return parsed;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// unreadable owner: treated as ownerless below
|
|
15
|
+
}
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* At Host start: close sessions whose run is gone or terminal, clean leftover
|
|
20
|
+
* files, and leave sessions of live or waiting runs alone.
|
|
21
|
+
*/
|
|
22
|
+
export async function sweepOrphanBrowserSessions(options) {
|
|
23
|
+
const root = options.socketRoot ?? defaultSocketRoot(process.platform);
|
|
24
|
+
const closed = [];
|
|
25
|
+
let dirs;
|
|
26
|
+
try {
|
|
27
|
+
dirs = await readdir(root);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return { closed };
|
|
31
|
+
}
|
|
32
|
+
const owners = [];
|
|
33
|
+
for (const name of dirs) {
|
|
34
|
+
const owner = await readOwner(path.join(root, name));
|
|
35
|
+
if (owner !== undefined)
|
|
36
|
+
owners.push(owner);
|
|
37
|
+
}
|
|
38
|
+
// Stage sessions attach to the anchor, so they go first.
|
|
39
|
+
owners.sort((x, y) => Number(x.anchor === true) - Number(y.anchor === true));
|
|
40
|
+
for (const owner of owners) {
|
|
41
|
+
if (await options.isRunLive(owner.runId).catch(() => true))
|
|
42
|
+
continue;
|
|
43
|
+
const { gone } = await closeBrowserSession(owner.env, {
|
|
44
|
+
...(options.runner !== undefined ? { runner: options.runner } : {}),
|
|
45
|
+
...(options.closeWaitMs !== undefined
|
|
46
|
+
? { closeWaitMs: options.closeWaitMs }
|
|
47
|
+
: {}),
|
|
48
|
+
});
|
|
49
|
+
if (gone)
|
|
50
|
+
closed.push(owner.env.AGENT_BROWSER_SESSION);
|
|
51
|
+
}
|
|
52
|
+
await rmdir(root).catch(() => undefined);
|
|
53
|
+
return { closed };
|
|
54
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { StageLogEvent } from "../runstore/port.js";
|
|
2
|
+
import { type BrowserEnv, type BrowserRunner, type StageBrowserSupport } from "./browserHost.js";
|
|
3
|
+
export declare const BROWSER_OWNER_FILENAME = "owner.json";
|
|
4
|
+
/** Written once a stage's tab and session are closed; cleared when its env is resolved again. */
|
|
5
|
+
export declare const BROWSER_CLOSED_FILENAME = "browser-closed.json";
|
|
6
|
+
export type BrowserSessionOwner = {
|
|
7
|
+
runId: string;
|
|
8
|
+
stageId: string;
|
|
9
|
+
runDir: string;
|
|
10
|
+
env: BrowserEnv;
|
|
11
|
+
/** The run's shared browser for `profile`; stage sessions attach to it. */
|
|
12
|
+
anchor?: true;
|
|
13
|
+
profile?: string;
|
|
14
|
+
};
|
|
15
|
+
export declare const defaultBrowserRunner: BrowserRunner;
|
|
16
|
+
export declare function writeSessionOwner(owner: BrowserSessionOwner): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Gracefully closes the session with the exact env it was launched with, waits
|
|
19
|
+
* (bounded) for the daemon to disappear, then removes the leftover socket-dir
|
|
20
|
+
* files. Never signals Chrome: a hard kill loses cookie persistence.
|
|
21
|
+
*/
|
|
22
|
+
export declare function closeBrowserSession(env: BrowserEnv, options?: {
|
|
23
|
+
runner?: BrowserRunner;
|
|
24
|
+
closeWaitMs?: number;
|
|
25
|
+
}): Promise<{
|
|
26
|
+
gone: boolean;
|
|
27
|
+
}>;
|
|
28
|
+
/**
|
|
29
|
+
* Closes one stage's tab (when it is attached to a shared browser) and then
|
|
30
|
+
* its session. Chrome, the lease and the other stages stay up. No-op for
|
|
31
|
+
* stages that never resolved a browser env.
|
|
32
|
+
*/
|
|
33
|
+
export declare function teardownStageBrowser(support: StageBrowserSupport, input: StageTeardownInput): Promise<void>;
|
|
34
|
+
type StageEvents = () => Promise<StageLogEvent[]>;
|
|
35
|
+
type StageTeardownInput = {
|
|
36
|
+
runId: string;
|
|
37
|
+
runDir: string;
|
|
38
|
+
stageId: string;
|
|
39
|
+
/** Reads the stage's persisted tool activity for the soft allowlist audit. */
|
|
40
|
+
events?: StageEvents;
|
|
41
|
+
};
|
|
42
|
+
/** Gracefully closes every anchor browser the run started. */
|
|
43
|
+
export declare function closeRunAnchors(support: StageBrowserSupport, run: {
|
|
44
|
+
runDir: string;
|
|
45
|
+
}): Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Run end, cancel, abandon or failure: closes stage sessions and tabs, then
|
|
48
|
+
* the run's anchors, then releases the run's profile leases. `only` closes
|
|
49
|
+
* just those stages and leaves anchor and leases alone.
|
|
50
|
+
*/
|
|
51
|
+
export declare function teardownRunBrowsers(support: StageBrowserSupport, run: {
|
|
52
|
+
runId: string;
|
|
53
|
+
runDir: string;
|
|
54
|
+
events?: (stageId: string) => Promise<StageLogEvent[]>;
|
|
55
|
+
}, only?: (stageId: string) => boolean): Promise<void>;
|
|
56
|
+
export {};
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { readFile, readdir, rm, rmdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { readPersistedBrowserEnv } from "./persistedEnv.js";
|
|
6
|
+
import { stageDir } from "../runstore/paths.js";
|
|
7
|
+
import { auditStageNavigations } from "./navigationAudit.js";
|
|
8
|
+
import { BROWSER_ENV_FILENAME, BROWSER_POLICY_FILENAME, } from "./browserHost.js";
|
|
9
|
+
import { stageProfileLock } from "./stageProfileLock.js";
|
|
10
|
+
import { BROWSER_ANCHOR_FILENAME, anchorDir, readPersistedAnchor, } from "./anchor.js";
|
|
11
|
+
export const BROWSER_OWNER_FILENAME = "owner.json";
|
|
12
|
+
/** Written once a stage's tab and session are closed; cleared when its env is resolved again. */
|
|
13
|
+
export const BROWSER_CLOSED_FILENAME = "browser-closed.json";
|
|
14
|
+
const DEFAULT_CLOSE_WAIT_MS = 10_000;
|
|
15
|
+
const POLL_MS = 50;
|
|
16
|
+
const CLOSE_COMMAND_TIMEOUT_MS = 20_000;
|
|
17
|
+
export const defaultBrowserRunner = (args, env, options) => new Promise((resolve) => {
|
|
18
|
+
execFile("agent-browser", args, {
|
|
19
|
+
env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", ...env },
|
|
20
|
+
timeout: options?.timeoutMs ?? CLOSE_COMMAND_TIMEOUT_MS,
|
|
21
|
+
}, (err, stdout) => {
|
|
22
|
+
const code = err?.code;
|
|
23
|
+
resolve({
|
|
24
|
+
code: err ? (typeof code === "number" ? code : 1) : 0,
|
|
25
|
+
stdout: String(stdout ?? ""),
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
export async function writeSessionOwner(owner) {
|
|
30
|
+
const dir = owner.env.AGENT_BROWSER_SOCKET_DIR;
|
|
31
|
+
if (!dir)
|
|
32
|
+
return;
|
|
33
|
+
await writeFile(path.join(dir, BROWSER_OWNER_FILENAME), `${JSON.stringify(owner)}\n`, { mode: 0o600 });
|
|
34
|
+
}
|
|
35
|
+
function pidAlive(pidFile) {
|
|
36
|
+
try {
|
|
37
|
+
const pid = Number.parseInt(readFileSync(pidFile, "utf8").trim(), 10);
|
|
38
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
39
|
+
return false;
|
|
40
|
+
process.kill(pid, 0);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
return err.code === "EPERM";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function daemonGone(dir, session) {
|
|
48
|
+
if (existsSync(path.join(dir, `${session}.sock`)))
|
|
49
|
+
return false;
|
|
50
|
+
const pidFile = path.join(dir, `${session}.pid`);
|
|
51
|
+
return !existsSync(pidFile) || !pidAlive(pidFile);
|
|
52
|
+
}
|
|
53
|
+
async function removeSessionFiles(dir, session) {
|
|
54
|
+
let names;
|
|
55
|
+
try {
|
|
56
|
+
names = await readdir(dir);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
for (const name of names) {
|
|
62
|
+
if (name.startsWith(`${session}.`) || name === BROWSER_OWNER_FILENAME) {
|
|
63
|
+
await rm(path.join(dir, name), { force: true });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
await rmdir(dir).catch(() => undefined);
|
|
67
|
+
await rmdir(path.dirname(dir)).catch(() => undefined);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Gracefully closes the session with the exact env it was launched with, waits
|
|
71
|
+
* (bounded) for the daemon to disappear, then removes the leftover socket-dir
|
|
72
|
+
* files. Never signals Chrome: a hard kill loses cookie persistence.
|
|
73
|
+
*/
|
|
74
|
+
// TODO(multi-tenant): teardown and the orphan sweep assume a local agent-browser runner and a local socket root.
|
|
75
|
+
// A remote BrowserHost (sandbox or provider) needs its own close and sweep path.
|
|
76
|
+
export async function closeBrowserSession(env, options = {}) {
|
|
77
|
+
const runner = options.runner ?? defaultBrowserRunner;
|
|
78
|
+
try {
|
|
79
|
+
await runner(["close"], env);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// close of an already-gone session is not an error
|
|
83
|
+
}
|
|
84
|
+
const dir = env.AGENT_BROWSER_SOCKET_DIR;
|
|
85
|
+
const session = env.AGENT_BROWSER_SESSION;
|
|
86
|
+
if (!dir || !session)
|
|
87
|
+
return { gone: true };
|
|
88
|
+
const deadline = Date.now() + (options.closeWaitMs ?? DEFAULT_CLOSE_WAIT_MS);
|
|
89
|
+
while (!daemonGone(dir, session)) {
|
|
90
|
+
if (Date.now() >= deadline)
|
|
91
|
+
return { gone: false };
|
|
92
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
93
|
+
}
|
|
94
|
+
await removeSessionFiles(dir, session);
|
|
95
|
+
return { gone: true };
|
|
96
|
+
}
|
|
97
|
+
async function readEnvFile(file) {
|
|
98
|
+
try {
|
|
99
|
+
return await readPersistedBrowserEnv(file);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function closeOptions(support) {
|
|
106
|
+
return {
|
|
107
|
+
...(support.runner !== undefined ? { runner: support.runner } : {}),
|
|
108
|
+
...(support.closeWaitMs !== undefined
|
|
109
|
+
? { closeWaitMs: support.closeWaitMs }
|
|
110
|
+
: {}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Closes one stage's tab (when it is attached to a shared browser) and then
|
|
115
|
+
* its session. Chrome, the lease and the other stages stay up. No-op for
|
|
116
|
+
* stages that never resolved a browser env.
|
|
117
|
+
*/
|
|
118
|
+
export function teardownStageBrowser(support, input) {
|
|
119
|
+
const key = `${input.runDir}\0${input.stageId}`;
|
|
120
|
+
const running = stageTeardowns.get(key);
|
|
121
|
+
if (running !== undefined)
|
|
122
|
+
return running;
|
|
123
|
+
const promise = teardownStageBrowserOnce(support, input).finally(() => {
|
|
124
|
+
stageTeardowns.delete(key);
|
|
125
|
+
});
|
|
126
|
+
stageTeardowns.set(key, promise);
|
|
127
|
+
return promise;
|
|
128
|
+
}
|
|
129
|
+
const stageTeardowns = new Map();
|
|
130
|
+
async function readNavigationPolicy(file) {
|
|
131
|
+
try {
|
|
132
|
+
const raw = JSON.parse(await readFile(file, "utf8"));
|
|
133
|
+
if (typeof raw.runId !== "string" ||
|
|
134
|
+
typeof raw.stageId !== "string" ||
|
|
135
|
+
!Array.isArray(raw.allow_domains)) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
runId: raw.runId,
|
|
140
|
+
stageId: raw.stageId,
|
|
141
|
+
...(typeof raw.profile === "string" ? { profile: raw.profile } : {}),
|
|
142
|
+
allow_domains: raw.allow_domains.filter((d) => typeof d === "string"),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function auditAndClearPolicy(support, input) {
|
|
150
|
+
const file = path.join(stageDir(input.runDir, input.stageId), BROWSER_POLICY_FILENAME);
|
|
151
|
+
const policy = await readNavigationPolicy(file);
|
|
152
|
+
if (policy === undefined)
|
|
153
|
+
return;
|
|
154
|
+
await auditStageNavigations(policy, input.events, support.audit).catch(() => undefined);
|
|
155
|
+
await rm(file, { force: true }).catch(() => undefined);
|
|
156
|
+
}
|
|
157
|
+
async function teardownStageBrowserOnce(support, input) {
|
|
158
|
+
const env = await readEnvFile(path.join(stageDir(input.runDir, input.stageId), BROWSER_ENV_FILENAME));
|
|
159
|
+
if (env === undefined)
|
|
160
|
+
return;
|
|
161
|
+
await auditAndClearPolicy(support, input);
|
|
162
|
+
const closedMarker = path.join(stageDir(input.runDir, input.stageId), BROWSER_CLOSED_FILENAME);
|
|
163
|
+
// A second close would re-attach to the shared browser and could close another stage's tab.
|
|
164
|
+
if (existsSync(closedMarker))
|
|
165
|
+
return;
|
|
166
|
+
if (env.AGENT_BROWSER_CDP !== undefined) {
|
|
167
|
+
// With the pinned tab as the current tab, `tab close` closes only this stage's tab.
|
|
168
|
+
await (support.runner ?? defaultBrowserRunner)(["tab", "close"], env).catch(() => undefined);
|
|
169
|
+
}
|
|
170
|
+
const { gone } = await closeBrowserSession(env, closeOptions(support));
|
|
171
|
+
if (gone)
|
|
172
|
+
await writeFile(closedMarker, "{}\n").catch(() => undefined);
|
|
173
|
+
}
|
|
174
|
+
/** Gracefully closes every anchor browser the run started. */
|
|
175
|
+
export function closeRunAnchors(support, run) {
|
|
176
|
+
const running = anchorTeardowns.get(run.runDir);
|
|
177
|
+
if (running !== undefined)
|
|
178
|
+
return running;
|
|
179
|
+
const promise = closeRunAnchorsOnce(support, run).finally(() => {
|
|
180
|
+
anchorTeardowns.delete(run.runDir);
|
|
181
|
+
});
|
|
182
|
+
anchorTeardowns.set(run.runDir, promise);
|
|
183
|
+
return promise;
|
|
184
|
+
}
|
|
185
|
+
const anchorTeardowns = new Map();
|
|
186
|
+
async function closeRunAnchorsOnce(support, run) {
|
|
187
|
+
let names = [];
|
|
188
|
+
try {
|
|
189
|
+
names = await readdir(path.join(run.runDir, "browser"));
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
for (const name of names) {
|
|
195
|
+
const anchor = await readPersistedAnchor(run.runDir, name);
|
|
196
|
+
if (anchor === undefined)
|
|
197
|
+
continue;
|
|
198
|
+
const { gone } = await closeBrowserSession(anchor.anchorEnv, closeOptions(support)).catch(() => ({ gone: false }));
|
|
199
|
+
if (gone) {
|
|
200
|
+
await rm(path.join(anchorDir(run.runDir, name), BROWSER_ANCHOR_FILENAME), {
|
|
201
|
+
force: true,
|
|
202
|
+
}).catch(() => undefined);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Run end, cancel, abandon or failure: closes stage sessions and tabs, then
|
|
208
|
+
* the run's anchors, then releases the run's profile leases. `only` closes
|
|
209
|
+
* just those stages and leaves anchor and leases alone.
|
|
210
|
+
*/
|
|
211
|
+
export async function teardownRunBrowsers(support, run, only) {
|
|
212
|
+
let ids = [];
|
|
213
|
+
try {
|
|
214
|
+
ids = await readdir(path.join(run.runDir, "stages"));
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// no stage dirs: nothing to close
|
|
218
|
+
}
|
|
219
|
+
for (const stageId of ids) {
|
|
220
|
+
if (only !== undefined && !only(stageId))
|
|
221
|
+
continue;
|
|
222
|
+
const { events, ...base } = run;
|
|
223
|
+
await teardownStageBrowser(support, {
|
|
224
|
+
...base,
|
|
225
|
+
stageId,
|
|
226
|
+
...(events !== undefined ? { events: () => events(stageId) } : {}),
|
|
227
|
+
}).catch(() => undefined);
|
|
228
|
+
}
|
|
229
|
+
if (only === undefined) {
|
|
230
|
+
await closeRunAnchors(support, run).catch(() => undefined);
|
|
231
|
+
await stageProfileLock(support)
|
|
232
|
+
.releaseOwner({ runId: run.runId })
|
|
233
|
+
.catch(() => undefined);
|
|
234
|
+
}
|
|
235
|
+
}
|