stageflow 0.27.0 → 0.31.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 +10 -0
- package/dist/agent/cursorProvider.js +83 -8
- 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/providerAuth.d.ts +7 -3
- package/dist/agent/providerAuth.js +5 -3
- 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/doctorCommand.js +7 -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/providersCommand.d.ts +1 -1
- package/dist/cli/providersCommand.js +4 -4
- 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 +32 -3
- 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/diskUsage.d.ts +1 -1
- package/dist/runstore/diskUsage.js +2 -7
- 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 +7 -2
- package/dist/runtime/credentialBinding.js +20 -28
- 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 +86 -1
- package/dist/runtime/repositoryMaterialize.d.ts +1 -1
- package/dist/runtime/runManager.d.ts +11 -0
- package/dist/runtime/runManager.js +161 -3
- 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 +7 -2
- package/dist/runtime/settingsFile.js +66 -56
- 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 +3 -0
- package/dist/runtime/stageProcessLauncher.js +52 -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 +70 -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-p8Iou3zk.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/conditional-fork/decide.yaml +1 -1
- package/examples/github-release/README.md +17 -3
- package/examples/stage-mcp/README.md +3 -2
- package/examples/stageflow.yaml +51 -0
- package/package.json +9 -4
- 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,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
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { shortHash, } from "./browserHost.js";
|
|
2
|
+
export function createFakeRemoteBrowserHost(address) {
|
|
3
|
+
return {
|
|
4
|
+
async ensureProfileBrowser(request) {
|
|
5
|
+
return {
|
|
6
|
+
cdpAddress: address,
|
|
7
|
+
anchorEnv: {
|
|
8
|
+
AGENT_BROWSER_CDP: address,
|
|
9
|
+
AGENT_BROWSER_SESSION: `sf-r-${shortHash(`${request.profile.key.scope}/${request.profile.key.name}`, 12)}`,
|
|
10
|
+
AGENT_BROWSER_IDLE_TIMEOUT_MS: "0",
|
|
11
|
+
},
|
|
12
|
+
restarted: false,
|
|
13
|
+
};
|
|
14
|
+
},
|
|
15
|
+
async profileBrowserEnv(request) {
|
|
16
|
+
return {
|
|
17
|
+
AGENT_BROWSER_CDP: address,
|
|
18
|
+
AGENT_BROWSER_SESSION: `sf-r-${shortHash(`${request.profile.key.scope}/${request.profile.key.name}`, 12)}`,
|
|
19
|
+
AGENT_BROWSER_IDLE_TIMEOUT_MS: "0",
|
|
20
|
+
};
|
|
21
|
+
},
|
|
22
|
+
async stageEnv(request) {
|
|
23
|
+
const identity = `${request.runId}/${request.stageId}`;
|
|
24
|
+
const env = {
|
|
25
|
+
AGENT_BROWSER_CDP: request.cdpAddress ?? address,
|
|
26
|
+
AGENT_BROWSER_SESSION: `sf-r-${shortHash(identity, 12)}`,
|
|
27
|
+
AGENT_BROWSER_IDLE_TIMEOUT_MS: "0",
|
|
28
|
+
};
|
|
29
|
+
if (request.profile !== undefined)
|
|
30
|
+
env.AGENT_BROWSER_PIN_TAB = "1";
|
|
31
|
+
return env;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { StageBrowserConfig } from "../types/stage.js";
|
|
2
|
+
export type GateHandoff = {
|
|
3
|
+
kind: "local_window";
|
|
4
|
+
} | {
|
|
5
|
+
kind: "live_view";
|
|
6
|
+
url: string;
|
|
7
|
+
};
|
|
8
|
+
/** Host-owned fields on a gate prompt for stages with a `browser`. Never a path. */
|
|
9
|
+
export type HostGateContext = {
|
|
10
|
+
handoff?: GateHandoff;
|
|
11
|
+
site?: string;
|
|
12
|
+
profile?: string;
|
|
13
|
+
};
|
|
14
|
+
export declare const HOST_GATE_KEYS: readonly ["handoff", "site", "profile"];
|
|
15
|
+
export declare function parseGateHandoff(value: unknown): GateHandoff | undefined;
|
|
16
|
+
/** Reads the Host-owned fields off a stored or live wait request; unknown shapes yield nothing. */
|
|
17
|
+
export declare function parseHostGateContext(value: unknown): HostGateContext;
|
|
18
|
+
export declare function browserSite(browser: StageBrowserConfig): string | undefined;
|
|
19
|
+
/** What the Host stamps on every gate of a browser stage; the agent cannot supply it. */
|
|
20
|
+
export declare function hostGateContextFor(browser: StageBrowserConfig | undefined): HostGateContext | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Overwrites (or, for stages without a browser, strips) the Host-owned keys of
|
|
23
|
+
* a wait request. Non-object requests pass through unchanged.
|
|
24
|
+
*/
|
|
25
|
+
export declare function stampGateRequest(request: unknown, context: HostGateContext | undefined): unknown;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export const HOST_GATE_KEYS = ["handoff", "site", "profile"];
|
|
2
|
+
export function parseGateHandoff(value) {
|
|
3
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
4
|
+
return undefined;
|
|
5
|
+
}
|
|
6
|
+
const record = value;
|
|
7
|
+
if (record.kind === "local_window")
|
|
8
|
+
return { kind: "local_window" };
|
|
9
|
+
if (record.kind === "live_view" && typeof record.url === "string") {
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(record.url);
|
|
12
|
+
if (url.protocol === "https:" || url.protocol === "http:") {
|
|
13
|
+
return { kind: "live_view", url: record.url };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
/** Reads the Host-owned fields off a stored or live wait request; unknown shapes yield nothing. */
|
|
23
|
+
export function parseHostGateContext(value) {
|
|
24
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
const record = value;
|
|
28
|
+
const handoff = parseGateHandoff(record.handoff);
|
|
29
|
+
return {
|
|
30
|
+
...(handoff !== undefined ? { handoff } : {}),
|
|
31
|
+
...(typeof record.site === "string" && record.site !== ""
|
|
32
|
+
? { site: record.site }
|
|
33
|
+
: {}),
|
|
34
|
+
...(typeof record.profile === "string" && record.profile !== ""
|
|
35
|
+
? { profile: record.profile }
|
|
36
|
+
: {}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function hostOf(url) {
|
|
40
|
+
try {
|
|
41
|
+
return new URL(url).hostname || undefined;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function browserSite(browser) {
|
|
48
|
+
if (browser.check !== undefined) {
|
|
49
|
+
const host = hostOf(browser.check.url);
|
|
50
|
+
if (host !== undefined)
|
|
51
|
+
return host;
|
|
52
|
+
}
|
|
53
|
+
const first = browser.allow_domains?.[0];
|
|
54
|
+
if (first !== undefined)
|
|
55
|
+
return first.replace(/^\*\./, "");
|
|
56
|
+
if (browser.login_url !== undefined)
|
|
57
|
+
return hostOf(browser.login_url);
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
/** What the Host stamps on every gate of a browser stage; the agent cannot supply it. */
|
|
61
|
+
export function hostGateContextFor(browser) {
|
|
62
|
+
if (browser === undefined)
|
|
63
|
+
return undefined;
|
|
64
|
+
const site = browserSite(browser);
|
|
65
|
+
return {
|
|
66
|
+
handoff: { kind: "local_window" },
|
|
67
|
+
...(site !== undefined ? { site } : {}),
|
|
68
|
+
...(browser.profile !== undefined ? { profile: browser.profile } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Overwrites (or, for stages without a browser, strips) the Host-owned keys of
|
|
73
|
+
* a wait request. Non-object requests pass through unchanged.
|
|
74
|
+
*/
|
|
75
|
+
export function stampGateRequest(request, context) {
|
|
76
|
+
if (request === null || typeof request !== "object" || Array.isArray(request)) {
|
|
77
|
+
return request;
|
|
78
|
+
}
|
|
79
|
+
const next = { ...request };
|
|
80
|
+
for (const key of HOST_GATE_KEYS)
|
|
81
|
+
delete next[key];
|
|
82
|
+
return context === undefined ? next : { ...next, ...context };
|
|
83
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ResolvedPipelineDag } from "../types/pipeline.js";
|
|
2
|
+
import type { StageBrowserConfig } from "../types/stage.js";
|
|
3
|
+
export type DisplayProbe = () => {
|
|
4
|
+
hasDisplay: boolean;
|
|
5
|
+
docker: boolean;
|
|
6
|
+
};
|
|
7
|
+
export declare const NO_SCREEN_MESSAGE = "A visible browser is needed for login, but this Host has no screen. A live view handoff is not available yet.";
|
|
8
|
+
export declare const DOCKER_HINT = " This Host runs in Docker: run the login stage on a machine with a screen, or log in there first and reuse the profile.";
|
|
9
|
+
/**
|
|
10
|
+
* A human login stage is a stage with a `browser` whose after-phase verify
|
|
11
|
+
* contains a `browser_login` item. The Host opens the login page in a visible
|
|
12
|
+
* window and the agent only asks the operator to confirm.
|
|
13
|
+
*/
|
|
14
|
+
export declare function isHumanLoginStage(dag: Pick<ResolvedPipelineDag, "nodes"> | undefined, stageId: string, browser: StageBrowserConfig | undefined): boolean;
|
|
15
|
+
export declare function loginPageUrl(browser: StageBrowserConfig): string | undefined;
|
|
16
|
+
export declare function defaultDisplayProbe(platform?: NodeJS.Platform, env?: Record<string, string | undefined>): ReturnType<DisplayProbe>;
|
|
17
|
+
export declare function noScreenError(docker: boolean): Error;
|
|
18
|
+
export declare function humanLoginPromptBlock(url: string | undefined): string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
export const NO_SCREEN_MESSAGE = "A visible browser is needed for login, but this Host has no screen. A live view handoff is not available yet.";
|
|
3
|
+
export const DOCKER_HINT = " This Host runs in Docker: run the login stage on a machine with a screen, or log in there first and reuse the profile.";
|
|
4
|
+
/**
|
|
5
|
+
* A human login stage is a stage with a `browser` whose after-phase verify
|
|
6
|
+
* contains a `browser_login` item. The Host opens the login page in a visible
|
|
7
|
+
* window and the agent only asks the operator to confirm.
|
|
8
|
+
*/
|
|
9
|
+
export function isHumanLoginStage(dag, stageId, browser) {
|
|
10
|
+
if (browser === undefined || dag === undefined)
|
|
11
|
+
return false;
|
|
12
|
+
const node = dag.nodes.find((n) => n.id === stageId);
|
|
13
|
+
return node?.completion?.checks.some((c) => c.type === "browser_login") === true;
|
|
14
|
+
}
|
|
15
|
+
export function loginPageUrl(browser) {
|
|
16
|
+
return browser.login_url ?? browser.check?.url;
|
|
17
|
+
}
|
|
18
|
+
export function defaultDisplayProbe(platform = process.platform, env = process.env) {
|
|
19
|
+
const docker = existsSync("/.dockerenv") || Boolean(env.container) || env.STAGEFLOW_IN_DOCKER === "1";
|
|
20
|
+
const hasDisplay = platform !== "linux" || Boolean(env.DISPLAY || env.WAYLAND_DISPLAY);
|
|
21
|
+
return { hasDisplay, docker };
|
|
22
|
+
}
|
|
23
|
+
export function noScreenError(docker) {
|
|
24
|
+
return new Error(`${NO_SCREEN_MESSAGE}${docker ? DOCKER_HINT : ""}`);
|
|
25
|
+
}
|
|
26
|
+
export function humanLoginPromptBlock(url) {
|
|
27
|
+
return [
|
|
28
|
+
"## Human login",
|
|
29
|
+
"",
|
|
30
|
+
`The Host has opened ${url !== undefined ? JSON.stringify(url) : "the login page"} in a visible browser window on the operator's screen.`,
|
|
31
|
+
"Do not type credentials and do not drive the browser. Call ask_operator with kind confirm: ask the operator to log in in that window and confirm when done. Leave the browser open.",
|
|
32
|
+
"After the operator accepts, emit the envelope. The Host re-checks the login itself; if it still sees a logged-out session, this stage runs again.",
|
|
33
|
+
].join("\n");
|
|
34
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supplies a Host-held secret for protecting browser state at rest. Not wired
|
|
3
|
+
* to agent-browser: its key only covers its own restore/state files, never the
|
|
4
|
+
* Chrome profile directory.
|
|
5
|
+
*/
|
|
6
|
+
export interface KeyProvider {
|
|
7
|
+
getKey(): Promise<string>;
|
|
8
|
+
}
|
|
9
|
+
export declare function localKeyFilePath(): string;
|
|
10
|
+
export declare function createLocalKeyProvider(file?: string): KeyProvider;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { globalStageflowHome } from "../project/globalHome.js";
|
|
5
|
+
export function localKeyFilePath() {
|
|
6
|
+
return path.join(globalStageflowHome(), "browser", "browser.key");
|
|
7
|
+
}
|
|
8
|
+
// TODO(multi-tenant): nothing in the runtime calls a KeyProvider yet, so browser.key is never created outside tests.
|
|
9
|
+
// A service needs a per-tenant key (KMS) and a decision on what it encrypts; the agent-browser key does not protect the Chrome profile.
|
|
10
|
+
export function createLocalKeyProvider(file) {
|
|
11
|
+
return {
|
|
12
|
+
async getKey() {
|
|
13
|
+
const target = file ?? localKeyFilePath();
|
|
14
|
+
try {
|
|
15
|
+
const existing = (await readFile(target, "utf8")).trim();
|
|
16
|
+
if (existing.length > 0) {
|
|
17
|
+
await chmod(target, 0o600);
|
|
18
|
+
return existing;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
if (err.code !== "ENOENT")
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
26
|
+
const key = randomBytes(32).toString("hex");
|
|
27
|
+
try {
|
|
28
|
+
await writeFile(target, `${key}\n`, { mode: 0o600, flag: "wx" });
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
if (err.code === "EEXIST") {
|
|
32
|
+
return (await readFile(target, "utf8")).trim();
|
|
33
|
+
}
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
return key;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type BrowserHost } from "./browserHost.js";
|
|
2
|
+
export type LocalBrowserHostOptions = {
|
|
3
|
+
platform?: NodeJS.Platform;
|
|
4
|
+
/** Read only for display detection; never for AGENT_BROWSER_* settings. */
|
|
5
|
+
hostEnv?: Record<string, string | undefined>;
|
|
6
|
+
socketRoot?: string;
|
|
7
|
+
emptyConfigPath?: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function defaultSocketRoot(platform: NodeJS.Platform): string;
|
|
10
|
+
export declare function createLocalBrowserHost(options?: LocalBrowserHostOptions): BrowserHost;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { globalStageflowHome } from "../project/globalHome.js";
|
|
5
|
+
import { shortHash, } from "./browserHost.js";
|
|
6
|
+
const MAX_SOCKET_PATH_BYTES = 103;
|
|
7
|
+
const MAX_SESSION_NAME_LENGTH = 48;
|
|
8
|
+
const DISPLAY_VARS = ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY"];
|
|
9
|
+
export function defaultSocketRoot(platform) {
|
|
10
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : "u";
|
|
11
|
+
const base = platform === "win32" ? os.tmpdir() : "/tmp";
|
|
12
|
+
return path.join(base, `sfb-${uid}`);
|
|
13
|
+
}
|
|
14
|
+
function sessionNameForProfile(scope, name) {
|
|
15
|
+
const plain = `sf-${name}`;
|
|
16
|
+
if (plain.length <= MAX_SESSION_NAME_LENGTH)
|
|
17
|
+
return plain;
|
|
18
|
+
return `${plain.slice(0, MAX_SESSION_NAME_LENGTH - 7)}-${shortHash(`${scope}/${name}`, 6)}`;
|
|
19
|
+
}
|
|
20
|
+
function sessionNameForProfileStage(profile, runId, stageId) {
|
|
21
|
+
const suffix = shortHash(`${runId}/${stageId}`, 6);
|
|
22
|
+
const plain = `sf-${profile.key.name}`;
|
|
23
|
+
const room = MAX_SESSION_NAME_LENGTH - suffix.length - 1;
|
|
24
|
+
return `${plain.slice(0, room)}-${suffix}`;
|
|
25
|
+
}
|
|
26
|
+
const OPEN_ANCHOR_TIMEOUT_MS = 60_000;
|
|
27
|
+
function hasDisplay(platform, hostEnv) {
|
|
28
|
+
if (platform !== "linux")
|
|
29
|
+
return true;
|
|
30
|
+
return Boolean(hostEnv.DISPLAY || hostEnv.WAYLAND_DISPLAY);
|
|
31
|
+
}
|
|
32
|
+
export function createLocalBrowserHost(options = {}) {
|
|
33
|
+
const platform = options.platform ?? process.platform;
|
|
34
|
+
const hostEnv = options.hostEnv ?? process.env;
|
|
35
|
+
async function build(input) {
|
|
36
|
+
const socketRoot = options.socketRoot ?? defaultSocketRoot(platform);
|
|
37
|
+
const socketDir = path.join(socketRoot, shortHash(input.identity, 8));
|
|
38
|
+
const socketPathBytes = Buffer.byteLength(path.join(socketDir, `${input.session}.sock`));
|
|
39
|
+
if (socketPathBytes > MAX_SOCKET_PATH_BYTES) {
|
|
40
|
+
throw new Error(`browser socket path is ${socketPathBytes} bytes (limit ${MAX_SOCKET_PATH_BYTES})`);
|
|
41
|
+
}
|
|
42
|
+
await mkdir(socketDir, { recursive: true, mode: 0o700 });
|
|
43
|
+
const emptyConfigPath = options.emptyConfigPath ??
|
|
44
|
+
path.join(globalStageflowHome(), "browser", "agent-browser.empty.json");
|
|
45
|
+
await mkdir(path.dirname(emptyConfigPath), { recursive: true, mode: 0o700 });
|
|
46
|
+
await writeFile(emptyConfigPath, "{}\n", { mode: 0o600 });
|
|
47
|
+
const env = {
|
|
48
|
+
AGENT_BROWSER_SESSION: input.session,
|
|
49
|
+
AGENT_BROWSER_SOCKET_DIR: socketDir,
|
|
50
|
+
AGENT_BROWSER_HEADED: input.headed ? "1" : "0",
|
|
51
|
+
AGENT_BROWSER_IDLE_TIMEOUT_MS: "0",
|
|
52
|
+
AGENT_BROWSER_CONFIG: emptyConfigPath,
|
|
53
|
+
};
|
|
54
|
+
input.set(env);
|
|
55
|
+
if (input.headed && platform === "linux") {
|
|
56
|
+
for (const name of DISPLAY_VARS) {
|
|
57
|
+
const value = hostEnv[name];
|
|
58
|
+
if (value)
|
|
59
|
+
env[name] = value;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return env;
|
|
63
|
+
}
|
|
64
|
+
function headedFor(request) {
|
|
65
|
+
return (request.humanLogin === true ||
|
|
66
|
+
((request.browser.headed ?? true) && hasDisplay(platform, hostEnv)));
|
|
67
|
+
}
|
|
68
|
+
async function profileBrowserEnv(request) {
|
|
69
|
+
const { profile } = request;
|
|
70
|
+
return build({
|
|
71
|
+
identity: `profile:${profile.key.scope}/${profile.key.name}:${profile.profileDir}`,
|
|
72
|
+
session: sessionNameForProfile(profile.key.scope, profile.key.name),
|
|
73
|
+
headed: headedFor(request),
|
|
74
|
+
set: (env) => {
|
|
75
|
+
env.AGENT_BROWSER_PROFILE = profile.profileDir;
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async function readCdpAddress(request, env) {
|
|
80
|
+
const got = await request.runner(["get", "cdp-url"], env, {
|
|
81
|
+
timeoutMs: OPEN_ANCHOR_TIMEOUT_MS,
|
|
82
|
+
});
|
|
83
|
+
const address = (got.stdout ?? "").trim();
|
|
84
|
+
if (got.code !== 0 || !/^wss?:\/\//.test(address)) {
|
|
85
|
+
throw new Error("could not read the shared browser address (agent-browser get cdp-url)");
|
|
86
|
+
}
|
|
87
|
+
return address;
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
async ensureProfileBrowser(request) {
|
|
91
|
+
const wantHeaded = headedFor(request);
|
|
92
|
+
let previous = request.previous;
|
|
93
|
+
if (previous !== undefined &&
|
|
94
|
+
wantHeaded &&
|
|
95
|
+
previous.anchorEnv.AGENT_BROWSER_HEADED !== "1") {
|
|
96
|
+
await request.runner(["close"], previous.anchorEnv).catch(() => undefined);
|
|
97
|
+
previous = undefined;
|
|
98
|
+
}
|
|
99
|
+
if (previous !== undefined) {
|
|
100
|
+
// Any command relaunches a dead anchor, so reading the address is the liveness probe.
|
|
101
|
+
const cdpAddress = await readCdpAddress(request, previous.anchorEnv);
|
|
102
|
+
return {
|
|
103
|
+
cdpAddress,
|
|
104
|
+
anchorEnv: previous.anchorEnv,
|
|
105
|
+
restarted: cdpAddress !== previous.cdpAddress,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const anchorEnv = await profileBrowserEnv(request);
|
|
109
|
+
const opened = await request.runner(["open", "about:blank"], anchorEnv, {
|
|
110
|
+
timeoutMs: OPEN_ANCHOR_TIMEOUT_MS,
|
|
111
|
+
});
|
|
112
|
+
if (opened.code !== 0)
|
|
113
|
+
throw new Error("could not start the shared browser");
|
|
114
|
+
const cdpAddress = await readCdpAddress(request, anchorEnv);
|
|
115
|
+
return {
|
|
116
|
+
cdpAddress,
|
|
117
|
+
anchorEnv,
|
|
118
|
+
restarted: request.previous !== undefined,
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
profileBrowserEnv,
|
|
122
|
+
async stageEnv(request) {
|
|
123
|
+
const { browser, profile } = request;
|
|
124
|
+
if (profile !== undefined) {
|
|
125
|
+
if (request.cdpAddress === undefined) {
|
|
126
|
+
throw new Error("a stage with a browser profile needs the shared browser address");
|
|
127
|
+
}
|
|
128
|
+
const cdp = request.cdpAddress;
|
|
129
|
+
return build({
|
|
130
|
+
identity: `stage:${request.runId}/${request.stageId}`,
|
|
131
|
+
session: sessionNameForProfileStage(profile, request.runId, request.stageId),
|
|
132
|
+
headed: headedFor(request),
|
|
133
|
+
set: (env) => {
|
|
134
|
+
env.AGENT_BROWSER_CDP = cdp;
|
|
135
|
+
env.AGENT_BROWSER_PIN_TAB = "1";
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const identity = `stage:${request.runId}/${request.stageId}`;
|
|
140
|
+
return build({
|
|
141
|
+
identity,
|
|
142
|
+
session: `sf-t-${shortHash(identity, 12)}`,
|
|
143
|
+
headed: headedFor(request),
|
|
144
|
+
set: (env) => {
|
|
145
|
+
if (browser.allow_domains && browser.allow_domains.length > 0) {
|
|
146
|
+
env.AGENT_BROWSER_ALLOWED_DOMAINS = browser.allow_domains.join(",");
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|