@loucompanion/forge-bridge 0.1.3-dev.d46a8e73d03d → 0.1.3-dev.eeefdb0d19c1
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 +34 -0
- package/dist/bridge/bridge.base.d.ts +2 -1
- package/dist/bridge/bridge.service.d.ts +22 -3
- package/dist/bridge/bridge.service.js +416 -40
- package/dist/bridge/bridge.types.d.ts +59 -1
- package/dist/cleanup/cleanup.service.d.ts +13 -2
- package/dist/cleanup/cleanup.service.js +435 -3
- package/dist/cleanup/cleanup.types.d.ts +10 -0
- package/dist/cleanup/index.d.ts +1 -1
- package/dist/cleanup/index.js +1 -1
- package/dist/cleanup/internals.d.ts +2 -2
- package/dist/cleanup/internals.js +1 -1
- package/dist/cli/cli.help.js +37 -3
- package/dist/cli/cli.service.d.ts +30 -2
- package/dist/cli/cli.service.js +182 -23
- package/dist/cli/local-config.d.ts +4 -0
- package/dist/cli/local-config.js +41 -1
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
- package/dist/runtime/attempt-node-runtime-launcher.js +101 -0
- package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
- package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
- package/dist/runtime/attempt-process-supervisor.d.ts +86 -0
- package/dist/runtime/attempt-process-supervisor.js +343 -0
- package/dist/runtime/generated-output-reset.d.ts +10 -0
- package/dist/runtime/generated-output-reset.js +103 -0
- package/dist/runtime/host-listener-inspector.d.ts +44 -0
- package/dist/runtime/host-listener-inspector.js +324 -0
- package/dist/runtime/host-port-broker.d.ts +76 -0
- package/dist/runtime/host-port-broker.js +333 -0
- package/dist/runtime/host-runtime-contract.d.ts +47 -0
- package/dist/runtime/host-runtime-contract.js +700 -0
- package/dist/runtime/host-runtime-lease.d.ts +133 -0
- package/dist/runtime/host-runtime-lease.js +1460 -0
- package/dist/runtime/host-runtime-promotion.d.ts +23 -0
- package/dist/runtime/host-runtime-promotion.js +159 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +51 -0
- package/dist/runtime/node-host-runtime-adapter.js +268 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +220 -0
- package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
- package/dist/runtime/runtime-isolation-lease.js +158 -0
- package/dist/sandbox/preflight.d.ts +14 -0
- package/dist/sandbox/preflight.js +91 -12
- package/dist/sandbox/process.js +125 -40
- package/dist/sandbox/repository-lock.js +37 -1
- package/dist/sandbox/runtime.d.ts +66 -3
- package/dist/sandbox/runtime.js +670 -105
- package/dist/sandbox/sandbox.types.d.ts +3 -0
- package/dist/sandbox/smoke.d.ts +76 -0
- package/dist/sandbox/smoke.js +380 -0
- package/dist/sandbox/worker-supervisor.js +1 -1
- package/dist/sandbox/worker.js +2 -5
- package/dist/sandbox/workspace.service.d.ts +1 -0
- package/dist/sandbox/workspace.service.js +28 -5
- package/dist/session/managed-attempt-tools.d.ts +12 -0
- package/dist/session/managed-attempt-tools.js +350 -0
- package/dist/session/phase-completion.artifacts.js +9 -2
- package/dist/session/phase-completion.d.ts +4 -2
- package/dist/session/phase-completion.helpers.js +27 -3
- package/dist/session/phase-completion.js +12 -3
- package/dist/session/phase-completion.prompts.d.ts +3 -3
- package/dist/session/phase-completion.prompts.js +19 -5
- package/dist/session/phase-completion.runtime.d.ts +12 -1
- package/dist/session/phase-completion.runtime.js +127 -7
- package/dist/session/phase-completion.schemas.js +108 -3
- package/dist/session/phase-completion.validation.d.ts +4 -1
- package/dist/session/phase-completion.validation.js +106 -2
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +90 -0
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +1 -1
- package/dist/session/provider-cli/provider-cli.artifacts.js +28 -4
- package/dist/session/provider-cli/provider-cli.deterministic.js +47 -1
- package/dist/session/provider-cli/provider-cli.process.d.ts +16 -0
- package/dist/session/provider-cli/provider-cli.process.js +212 -25
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +4 -1
- package/dist/session/provider-cli/provider-cli.prompts.js +84 -17
- package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
- package/dist/session/provider-cli/provider-cli.runtime.js +3 -2
- package/dist/session/provider-cli/provider-cli.service.d.ts +3 -2
- package/dist/session/provider-cli/provider-cli.service.js +254 -71
- package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
- package/dist/session/provider-cli/provider-output-state.js +30 -2
- package/dist/session/session-redaction.d.ts +3 -0
- package/dist/session/session-redaction.js +23 -0
- package/dist/session/session.base.d.ts +6 -1
- package/dist/session/session.service.d.ts +8 -2
- package/dist/session/session.service.js +436 -41
- package/dist/session/session.types.d.ts +55 -0
- package/dist/session/session.types.js +69 -2
- package/dist/shared/path/path.js +3 -1
- package/dist/shared/process/host-process-recovery.d.ts +12 -0
- package/dist/shared/process/host-process-recovery.js +78 -0
- package/dist/shared/process/host-process-supervisor.d.ts +57 -0
- package/dist/shared/process/host-process-supervisor.js +282 -0
- package/dist/shared/process/process.d.ts +1 -0
- package/dist/shared/process/process.js +1 -0
- package/dist/shared/redaction/redaction.js +6 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +3 -2
- package/dist/worktree/artifact-injector.js +43 -4
- package/dist/worktree/dependency-warmer.d.ts +10 -1
- package/dist/worktree/dependency-warmer.js +94 -5
- package/dist/worktree/index.d.ts +1 -0
- package/dist/worktree/index.js +1 -0
- package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
- package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
- package/dist/worktree/worktree-paths.d.ts +4 -0
- package/dist/worktree/worktree-paths.js +30 -0
- package/dist/worktree/worktree.base.d.ts +3 -0
- package/dist/worktree/worktree.service.d.ts +7 -2
- package/dist/worktree/worktree.service.js +72 -44
- package/dist/worktree/worktree.types.d.ts +3 -1
- package/package.json +3 -2
- package/runtime/windows-job-supervisor.ps1 +356 -0
- package/runtime/windows-listener-owner.ps1 +33 -0
|
@@ -1,57 +1,65 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { RepositoryCoordinatorLock } from "../sandbox/repository-lock.js";
|
|
3
4
|
import { GitClient } from "../shared/git/git.js";
|
|
4
5
|
import { ensureDirectory, isSubPath, resolveExistingDirectoryPath, safeSegment } from "../shared/path/path.js";
|
|
5
6
|
import { filterArtifactsForLane, injectWorktreeArtifacts } from "./artifact-injector.js";
|
|
7
|
+
import { hydratePriorPhaseArtifacts } from "./prior-phase-artifact-hydrator.js";
|
|
6
8
|
import { warmWorktreeDependencies } from "./dependency-warmer.js";
|
|
9
|
+
import { defaultWorktreeCoordinatorStateRoot, managedRepoRoot, resolveManagedWorktreeRoot, } from "./worktree-paths.js";
|
|
7
10
|
export class GitWorktreeService {
|
|
8
11
|
git;
|
|
9
|
-
|
|
12
|
+
coordinator;
|
|
13
|
+
constructor(git = new GitClient(), coordinator = new RepositoryCoordinatorLock(defaultWorktreeCoordinatorStateRoot())) {
|
|
10
14
|
this.git = git;
|
|
15
|
+
this.coordinator = coordinator;
|
|
11
16
|
}
|
|
12
17
|
async prepare(request) {
|
|
13
18
|
const allowedRoot = await ensureDirectory(request.allowedRepoRoot);
|
|
14
19
|
const baseBranch = normalizeBranchName(request.baseBranch, "develop");
|
|
15
|
-
const repoPath = await this.resolveRepoPath(request.repo, allowedRoot, baseBranch);
|
|
16
|
-
await this.ensureGitIdentity(repoPath);
|
|
17
|
-
const baseStartPoint = await this.resolveBaseStartPoint(repoPath, baseBranch);
|
|
18
20
|
const shortId = request.workItem.id.replace(/-/g, "").slice(0, 12);
|
|
19
21
|
const kind = safeSegment(request.workItem.kind, "work-item");
|
|
20
22
|
const fallbackWorktreeKey = `${kind}/${shortId}`;
|
|
21
23
|
const worktreeKey = request.worktreeKey?.trim() || fallbackWorktreeKey;
|
|
22
24
|
const worktreeSegments = worktreeKeySegments(worktreeKey, kind, shortId);
|
|
23
25
|
const branchName = normalizeBranchName(worktreeSegments.join("/"), "work-item/unknown");
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
await
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
26
|
+
const creationCoordinationPath = this.repoCreationCoordinationPath(request.repo, allowedRoot);
|
|
27
|
+
const repoPath = await this.coordinator.withLock(creationCoordinationPath, async () => {
|
|
28
|
+
return await this.resolveRepoPath(request.repo, allowedRoot, baseBranch);
|
|
29
|
+
});
|
|
30
|
+
const resolvedWorktreePath = await this.coordinator.withLock(repoPath, async () => {
|
|
31
|
+
await this.ensureGitIdentity(repoPath);
|
|
32
|
+
const baseStartPoint = await this.resolveBaseStartPoint(repoPath, baseBranch);
|
|
33
|
+
const worktreeRoot = resolveManagedWorktreeRoot(allowedRoot, repoPath);
|
|
34
|
+
const worktreePath = path.join(worktreeRoot, ...worktreeSegments);
|
|
35
|
+
const resolvedWorktreeParent = await ensureDirectory(path.dirname(worktreePath));
|
|
36
|
+
if (!isSubPath(allowedRoot, resolvedWorktreeParent)) {
|
|
37
|
+
throw new Error(`Worktree path must stay under ${allowedRoot}.`);
|
|
38
|
+
}
|
|
39
|
+
if (!(await directoryExists(worktreePath))) {
|
|
40
|
+
await this.git.run(repoPath, ["worktree", "add", "-B", branchName, worktreePath, baseStartPoint]);
|
|
41
|
+
return await resolveExistingDirectoryPath(worktreePath);
|
|
42
|
+
}
|
|
43
|
+
const existingWorktreePath = await resolveExistingDirectoryPath(worktreePath);
|
|
44
|
+
if (!isSubPath(allowedRoot, existingWorktreePath)) {
|
|
39
45
|
throw new Error(`Worktree path must stay under ${allowedRoot}.`);
|
|
40
46
|
}
|
|
41
|
-
const currentBranch = await this.currentBranch(
|
|
47
|
+
const currentBranch = await this.currentBranch(existingWorktreePath);
|
|
42
48
|
if (currentBranch !== branchName) {
|
|
43
|
-
throw new Error(`Existing worktree ${
|
|
49
|
+
throw new Error(`Existing worktree ${existingWorktreePath} is on branch '${currentBranch}', expected '${branchName}'.`);
|
|
44
50
|
}
|
|
45
51
|
if (shouldResetExistingWorktree(request)) {
|
|
46
|
-
await this.resetWorktreeToBase(
|
|
52
|
+
await this.resetWorktreeToBase(existingWorktreePath, baseStartPoint);
|
|
47
53
|
}
|
|
48
|
-
|
|
54
|
+
return existingWorktreePath;
|
|
55
|
+
});
|
|
49
56
|
await this.ensureGitIdentity(resolvedWorktreePath);
|
|
50
57
|
await clearRunArtifactRoots(resolvedWorktreePath, request);
|
|
58
|
+
const hydratedPriorPhaseArtifactCount = await hydratePriorPhaseArtifacts(resolvedWorktreePath, request.priorPhaseArtifacts);
|
|
51
59
|
const qaReworkPacketPath = await materializeQaReworkPacket(resolvedWorktreePath, request);
|
|
52
60
|
const artifacts = filterArtifactsForLane(request.artifacts, request.lane, request.phase);
|
|
53
61
|
if (artifacts.length > 0) {
|
|
54
|
-
await injectWorktreeArtifacts(resolvedWorktreePath, artifacts);
|
|
62
|
+
await injectWorktreeArtifacts(resolvedWorktreePath, artifacts, await this.trackedForgePaths(resolvedWorktreePath));
|
|
55
63
|
}
|
|
56
64
|
const dependencyWarm = await warmWorktreeDependencies({
|
|
57
65
|
baseCheckoutPath: repoPath,
|
|
@@ -66,20 +74,36 @@ export class GitWorktreeService {
|
|
|
66
74
|
baseBranch,
|
|
67
75
|
cleanupWorktrees: request.cleanupWorktrees,
|
|
68
76
|
dependencyWarm,
|
|
69
|
-
qaReworkPacketPath
|
|
77
|
+
qaReworkPacketPath,
|
|
78
|
+
hydratedPriorPhaseArtifactCount
|
|
70
79
|
};
|
|
71
80
|
}
|
|
81
|
+
async trackedForgePaths(worktreePath) {
|
|
82
|
+
const output = await this.git.run(worktreePath, ["ls-files", "-z", "--", ".forge"]);
|
|
83
|
+
return new Set(output.split("\0").filter(Boolean).map(value => value.replaceAll("\\", "/")));
|
|
84
|
+
}
|
|
85
|
+
repoCreationCoordinationPath(repo, allowedRoot) {
|
|
86
|
+
if (repo.serverLocalPath && repo.serverLocalPath.trim().length > 0) {
|
|
87
|
+
return path.resolve(repo.serverLocalPath);
|
|
88
|
+
}
|
|
89
|
+
return this.managedRepoPath(repo, allowedRoot);
|
|
90
|
+
}
|
|
72
91
|
async release(handle) {
|
|
73
|
-
if (!handle.cleanupWorktrees
|
|
92
|
+
if (!handle.cleanupWorktrees) {
|
|
74
93
|
return;
|
|
75
94
|
}
|
|
76
|
-
|
|
77
|
-
await
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
95
|
+
await this.coordinator.withLock(handle.repoPath, async () => {
|
|
96
|
+
if (!(await directoryExists(handle.worktreePath))) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
await this.git.run(handle.repoPath, ["worktree", "remove", "--force", handle.worktreePath]);
|
|
101
|
+
await this.git.run(handle.repoPath, ["worktree", "prune"]);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
await fs.rm(handle.worktreePath, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
83
107
|
}
|
|
84
108
|
async resolveRepoPath(repo, allowedRoot, baseBranch) {
|
|
85
109
|
if (repo.serverLocalPath && repo.serverLocalPath.trim().length > 0) {
|
|
@@ -96,7 +120,7 @@ export class GitWorktreeService {
|
|
|
96
120
|
if (discovered) {
|
|
97
121
|
return discovered;
|
|
98
122
|
}
|
|
99
|
-
const repoPath =
|
|
123
|
+
const repoPath = this.managedRepoPath(repo, allowedRoot);
|
|
100
124
|
if (!(await gitMetadataExists(repoPath, allowedRoot))) {
|
|
101
125
|
const repoPathExists = await pathExists(repoPath);
|
|
102
126
|
if (repoPathExists) {
|
|
@@ -127,6 +151,9 @@ export class GitWorktreeService {
|
|
|
127
151
|
}
|
|
128
152
|
return resolved;
|
|
129
153
|
}
|
|
154
|
+
managedRepoPath(repo, allowedRoot) {
|
|
155
|
+
return path.join(managedRepoRoot(allowedRoot), `${safeSegment(repo.name, "repo")}-${repo.id.replace(/-/g, "")}`);
|
|
156
|
+
}
|
|
130
157
|
async findExistingRepoPath(repo, allowedRoot) {
|
|
131
158
|
const expectedRemote = normalizeRemote(repo.remoteUrl);
|
|
132
159
|
const rawName = repo.name.trim();
|
|
@@ -168,6 +195,13 @@ export class GitWorktreeService {
|
|
|
168
195
|
return isSubPath(allowedRoot, resolved) ? resolved : null;
|
|
169
196
|
}
|
|
170
197
|
async resolveBaseStartPoint(repoPath, baseBranch) {
|
|
198
|
+
if (baseBranch === "HEAD") {
|
|
199
|
+
const head = (await this.git.run(repoPath, ["rev-parse", "--verify", "HEAD^{commit}"])).trim();
|
|
200
|
+
if (!/^[0-9a-f]{40,64}$/iu.test(head)) {
|
|
201
|
+
throw new Error("The repository checkout does not have a valid committed HEAD revision.");
|
|
202
|
+
}
|
|
203
|
+
return head;
|
|
204
|
+
}
|
|
171
205
|
const localRef = `refs/heads/${baseBranch}`;
|
|
172
206
|
const remoteRef = `refs/remotes/origin/${baseBranch}`;
|
|
173
207
|
const origin = await this.git.run(repoPath, ["remote", "get-url", "origin"]).catch(() => "");
|
|
@@ -199,15 +233,6 @@ export class GitWorktreeService {
|
|
|
199
233
|
await this.git.run(repoPath, ["config", "user.name", "Forge Bridge"]);
|
|
200
234
|
}
|
|
201
235
|
}
|
|
202
|
-
function managedRepoRoot(allowedRoot) {
|
|
203
|
-
return path.join(allowedRoot, ".forge-worktrees", "_repos");
|
|
204
|
-
}
|
|
205
|
-
function resolveWorktreeRoot(allowedRoot, repoPath) {
|
|
206
|
-
const baseName = path.basename(repoPath);
|
|
207
|
-
return isSubPath(managedRepoRoot(allowedRoot), repoPath)
|
|
208
|
-
? path.join(allowedRoot, ".forge-worktrees", baseName)
|
|
209
|
-
: path.join(path.dirname(repoPath), ".forge-worktrees", baseName);
|
|
210
|
-
}
|
|
211
236
|
function isCloneableRemote(remoteUrl) {
|
|
212
237
|
const trimmed = remoteUrl?.trim() ?? "";
|
|
213
238
|
if (trimmed.length === 0 || trimmed.startsWith("local://")) {
|
|
@@ -215,9 +240,12 @@ function isCloneableRemote(remoteUrl) {
|
|
|
215
240
|
}
|
|
216
241
|
return trimmed.startsWith("git@") || trimmed.includes("://") || path.isAbsolute(trimmed);
|
|
217
242
|
}
|
|
218
|
-
async function clearRunArtifactRoots(worktreePath, request) {
|
|
243
|
+
export async function clearRunArtifactRoots(worktreePath, request) {
|
|
219
244
|
const roots = new Set();
|
|
220
245
|
roots.add(workItemArtifactRoot(worktreePath, request.workItem.id));
|
|
246
|
+
for (const artifact of request.priorPhaseArtifacts ?? []) {
|
|
247
|
+
roots.add(workItemArtifactRoot(worktreePath, artifact.workItemId));
|
|
248
|
+
}
|
|
221
249
|
const ownerType = normalizeOwnerType(request.ownerType);
|
|
222
250
|
const ownerId = request.ownerId?.trim();
|
|
223
251
|
if (ownerType && ownerId) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DispatchQaReworkContext, DispatchRepo, DispatchWorkItem, DispatchResolvedArtifact } from "../session/session.types.js";
|
|
1
|
+
import type { DispatchQaReworkContext, DispatchPriorPhaseArtifact, DispatchRepo, DispatchWorkItem, DispatchResolvedArtifact } from "../session/session.types.js";
|
|
2
2
|
import type { WarmDependenciesResult } from "./dependency-warmer.js";
|
|
3
3
|
export interface WorktreePrepareRequest {
|
|
4
4
|
readonly repo: DispatchRepo;
|
|
@@ -12,6 +12,7 @@ export interface WorktreePrepareRequest {
|
|
|
12
12
|
readonly allowedRepoRoot: string;
|
|
13
13
|
readonly cleanupWorktrees: boolean;
|
|
14
14
|
readonly artifacts: readonly DispatchResolvedArtifact[];
|
|
15
|
+
readonly priorPhaseArtifacts?: readonly DispatchPriorPhaseArtifact[];
|
|
15
16
|
readonly qaRework?: DispatchQaReworkContext | null;
|
|
16
17
|
readonly warmPathOverrides?: readonly string[];
|
|
17
18
|
}
|
|
@@ -23,4 +24,5 @@ export interface WorktreeHandle {
|
|
|
23
24
|
readonly cleanupWorktrees: boolean;
|
|
24
25
|
readonly dependencyWarm?: WarmDependenciesResult;
|
|
25
26
|
readonly qaReworkPacketPath?: string;
|
|
27
|
+
readonly hydratedPriorPhaseArtifactCount?: number;
|
|
26
28
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loucompanion/forge-bridge",
|
|
3
|
-
"version": "0.1.3-dev.
|
|
3
|
+
"version": "0.1.3-dev.eeefdb0d19c1",
|
|
4
4
|
"description": "Forge runner bridge CLI for connecting local developer machines to Forge dispatch.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
21
|
"dist",
|
|
22
|
+
"runtime",
|
|
22
23
|
"README.md"
|
|
23
24
|
],
|
|
24
25
|
"repository": {
|
|
@@ -41,7 +42,7 @@
|
|
|
41
42
|
"test": "vitest run",
|
|
42
43
|
"test:watch": "vitest",
|
|
43
44
|
"smoke:package": "npm run build && node scripts/smoke-package.mjs",
|
|
44
|
-
"lint": "eslint \"src/**/*.ts\" \"scripts/**/*.mjs\" \"vitest.config.ts\""
|
|
45
|
+
"lint": "eslint \"src/**/*.ts\" \"scripts/**/*.mjs\" \"vitest.config.ts\" \"vitest.setup.ts\""
|
|
45
46
|
},
|
|
46
47
|
"dependencies": {
|
|
47
48
|
"@microsoft/signalr": "^8.0.17",
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[Parameter(Mandatory = $true)]
|
|
3
|
+
[ValidateSet("Outer", "Inner")]
|
|
4
|
+
[string]$Mode,
|
|
5
|
+
|
|
6
|
+
[Parameter(Mandatory = $true)]
|
|
7
|
+
[string]$PayloadPath,
|
|
8
|
+
|
|
9
|
+
[string]$GateName
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
$ErrorActionPreference = "Stop"
|
|
13
|
+
|
|
14
|
+
Add-Type -TypeDefinition @"
|
|
15
|
+
using System;
|
|
16
|
+
using System.Runtime.InteropServices;
|
|
17
|
+
using System.Text;
|
|
18
|
+
|
|
19
|
+
public static class ForgeWindowsJob
|
|
20
|
+
{
|
|
21
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
22
|
+
private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
|
|
23
|
+
{
|
|
24
|
+
public long PerProcessUserTimeLimit;
|
|
25
|
+
public long PerJobUserTimeLimit;
|
|
26
|
+
public uint LimitFlags;
|
|
27
|
+
public UIntPtr MinimumWorkingSetSize;
|
|
28
|
+
public UIntPtr MaximumWorkingSetSize;
|
|
29
|
+
public uint ActiveProcessLimit;
|
|
30
|
+
public UIntPtr Affinity;
|
|
31
|
+
public uint PriorityClass;
|
|
32
|
+
public uint SchedulingClass;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
36
|
+
private struct IO_COUNTERS
|
|
37
|
+
{
|
|
38
|
+
public ulong ReadOperationCount;
|
|
39
|
+
public ulong WriteOperationCount;
|
|
40
|
+
public ulong OtherOperationCount;
|
|
41
|
+
public ulong ReadTransferCount;
|
|
42
|
+
public ulong WriteTransferCount;
|
|
43
|
+
public ulong OtherTransferCount;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
47
|
+
private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
|
48
|
+
{
|
|
49
|
+
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
|
|
50
|
+
public IO_COUNTERS IoInfo;
|
|
51
|
+
public UIntPtr ProcessMemoryLimit;
|
|
52
|
+
public UIntPtr JobMemoryLimit;
|
|
53
|
+
public UIntPtr PeakProcessMemoryUsed;
|
|
54
|
+
public UIntPtr PeakJobMemoryUsed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
|
|
58
|
+
private const int JobObjectExtendedLimitInformation = 9;
|
|
59
|
+
|
|
60
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
61
|
+
private static extern IntPtr CreateJobObject(IntPtr securityAttributes, string name);
|
|
62
|
+
|
|
63
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
64
|
+
private static extern bool SetInformationJobObject(
|
|
65
|
+
IntPtr job,
|
|
66
|
+
int informationClass,
|
|
67
|
+
IntPtr information,
|
|
68
|
+
uint informationLength);
|
|
69
|
+
|
|
70
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
71
|
+
public static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
|
|
72
|
+
|
|
73
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
74
|
+
public static extern bool TerminateJobObject(IntPtr job, uint exitCode);
|
|
75
|
+
|
|
76
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
77
|
+
public static extern bool CloseHandle(IntPtr handle);
|
|
78
|
+
|
|
79
|
+
public static IntPtr CreateKillOnCloseJob(string name)
|
|
80
|
+
{
|
|
81
|
+
IntPtr job = CreateJobObject(IntPtr.Zero, name);
|
|
82
|
+
if (job == IntPtr.Zero)
|
|
83
|
+
{
|
|
84
|
+
throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), "CreateJobObject failed.");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
var limits = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION();
|
|
88
|
+
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
|
89
|
+
int size = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION));
|
|
90
|
+
IntPtr pointer = Marshal.AllocHGlobal(size);
|
|
91
|
+
try
|
|
92
|
+
{
|
|
93
|
+
Marshal.StructureToPtr(limits, pointer, false);
|
|
94
|
+
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, pointer, (uint)size))
|
|
95
|
+
{
|
|
96
|
+
throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), "SetInformationJobObject failed.");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch
|
|
100
|
+
{
|
|
101
|
+
CloseHandle(job);
|
|
102
|
+
throw;
|
|
103
|
+
}
|
|
104
|
+
finally
|
|
105
|
+
{
|
|
106
|
+
Marshal.FreeHGlobal(pointer);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return job;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
public static string BuildArguments(object[] arguments)
|
|
113
|
+
{
|
|
114
|
+
var result = new StringBuilder();
|
|
115
|
+
for (int index = 0; index < arguments.Length; index++)
|
|
116
|
+
{
|
|
117
|
+
if (index > 0) result.Append(' ');
|
|
118
|
+
result.Append(QuoteArgument(Convert.ToString(arguments[index]) ?? String.Empty));
|
|
119
|
+
}
|
|
120
|
+
return result.ToString();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private static string QuoteArgument(string argument)
|
|
124
|
+
{
|
|
125
|
+
if (argument.Length > 0 && argument.IndexOfAny(new[] { ' ', '\t', '\n', '\v', '"' }) < 0)
|
|
126
|
+
{
|
|
127
|
+
return argument;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
var result = new StringBuilder();
|
|
131
|
+
result.Append('"');
|
|
132
|
+
int backslashes = 0;
|
|
133
|
+
foreach (char character in argument)
|
|
134
|
+
{
|
|
135
|
+
if (character == '\\')
|
|
136
|
+
{
|
|
137
|
+
backslashes++;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (character == '"')
|
|
142
|
+
{
|
|
143
|
+
result.Append('\\', backslashes * 2 + 1);
|
|
144
|
+
result.Append('"');
|
|
145
|
+
backslashes = 0;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
result.Append('\\', backslashes);
|
|
150
|
+
backslashes = 0;
|
|
151
|
+
result.Append(character);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
result.Append('\\', backslashes * 2);
|
|
155
|
+
result.Append('"');
|
|
156
|
+
return result.ToString();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
"@
|
|
160
|
+
|
|
161
|
+
function Read-ForgeSupervisorPayload {
|
|
162
|
+
if (-not [IO.Path]::IsPathRooted($PayloadPath)) {
|
|
163
|
+
throw "The supervisor payload path must be absolute."
|
|
164
|
+
}
|
|
165
|
+
$json = [IO.File]::ReadAllText($PayloadPath, [Text.Encoding]::UTF8)
|
|
166
|
+
return $json | ConvertFrom-Json
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function New-ForgeChildProcess(
|
|
170
|
+
[string]$command,
|
|
171
|
+
[object[]]$arguments,
|
|
172
|
+
[string]$workingDirectory,
|
|
173
|
+
[bool]$redirectStreams
|
|
174
|
+
) {
|
|
175
|
+
$maximumAttempts = 3
|
|
176
|
+
for ($attempt = 1; $attempt -le $maximumAttempts; $attempt += 1) {
|
|
177
|
+
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
|
|
178
|
+
$startInfo.FileName = $command
|
|
179
|
+
$startInfo.Arguments = [ForgeWindowsJob]::BuildArguments($arguments)
|
|
180
|
+
$startInfo.WorkingDirectory = $workingDirectory
|
|
181
|
+
$startInfo.UseShellExecute = $false
|
|
182
|
+
$startInfo.CreateNoWindow = $true
|
|
183
|
+
$startInfo.RedirectStandardInput = $redirectStreams
|
|
184
|
+
$startInfo.RedirectStandardOutput = $redirectStreams
|
|
185
|
+
$startInfo.RedirectStandardError = $redirectStreams
|
|
186
|
+
|
|
187
|
+
$child = New-Object System.Diagnostics.Process
|
|
188
|
+
$child.StartInfo = $startInfo
|
|
189
|
+
try {
|
|
190
|
+
if (-not $child.Start()) {
|
|
191
|
+
throw "Failed to start supervised process '$command'."
|
|
192
|
+
}
|
|
193
|
+
$child | Add-Member -NotePropertyName ForgeStartAttempt -NotePropertyValue $attempt
|
|
194
|
+
return $child
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
$nativeError = $_.Exception
|
|
198
|
+
while ($nativeError.InnerException) {
|
|
199
|
+
$nativeError = $nativeError.InnerException
|
|
200
|
+
}
|
|
201
|
+
$retryable = $nativeError -is [ComponentModel.Win32Exception] -and $nativeError.NativeErrorCode -in @(5, 32, 33)
|
|
202
|
+
$child.Dispose()
|
|
203
|
+
if (-not $retryable -or $attempt -eq $maximumAttempts) {
|
|
204
|
+
throw
|
|
205
|
+
}
|
|
206
|
+
Start-Sleep -Milliseconds (100 * $attempt)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function Write-ForgeProviderReady([object]$request, [Diagnostics.Process]$provider) {
|
|
212
|
+
$readyPath = [string]$request.readyPath
|
|
213
|
+
if ([string]::IsNullOrWhiteSpace($readyPath) -or -not [IO.Path]::IsPathRooted($readyPath)) {
|
|
214
|
+
throw "The provider-ready path must be absolute."
|
|
215
|
+
}
|
|
216
|
+
$payloadDirectory = [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($PayloadPath))
|
|
217
|
+
$readyDirectory = [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($readyPath))
|
|
218
|
+
if (-not [string]::Equals($payloadDirectory, $readyDirectory, [StringComparison]::OrdinalIgnoreCase)) {
|
|
219
|
+
throw "The provider-ready path must be beside the supervisor payload."
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
$ready = @{
|
|
223
|
+
schemaVersion = 1
|
|
224
|
+
supervisorId = [string]$request.supervisorId
|
|
225
|
+
rootPid = [int]$env:FORGE_SUPERVISOR_ROOT_PID
|
|
226
|
+
rootIdentity = [string]$env:FORGE_SUPERVISOR_ROOT_IDENTITY
|
|
227
|
+
providerPid = $provider.Id
|
|
228
|
+
providerCreatedAt = $provider.StartTime.ToUniversalTime().ToString("O")
|
|
229
|
+
providerIdentity = "win32:$($provider.StartTime.ToUniversalTime().Ticks)"
|
|
230
|
+
startAttempt = [int]$provider.ForgeStartAttempt
|
|
231
|
+
} | ConvertTo-Json -Compress
|
|
232
|
+
$temporaryPath = "$readyPath.$([Guid]::NewGuid().ToString('N')).tmp"
|
|
233
|
+
[IO.File]::WriteAllText($temporaryPath, "$ready`n", (New-Object Text.UTF8Encoding($false)))
|
|
234
|
+
[IO.File]::Move($temporaryPath, $readyPath)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function Wait-ForgeProviderStartGrant([object]$request) {
|
|
238
|
+
$grantPath = [string]$request.startGrantPath
|
|
239
|
+
if ([string]::IsNullOrWhiteSpace($grantPath) -or -not [IO.Path]::IsPathRooted($grantPath)) {
|
|
240
|
+
throw "The provider start-grant path must be absolute."
|
|
241
|
+
}
|
|
242
|
+
$payloadDirectory = [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($PayloadPath))
|
|
243
|
+
$grantDirectory = [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($grantPath))
|
|
244
|
+
if (-not [string]::Equals($payloadDirectory, $grantDirectory, [StringComparison]::OrdinalIgnoreCase)) {
|
|
245
|
+
throw "The provider start-grant path must be beside the supervisor payload."
|
|
246
|
+
}
|
|
247
|
+
for ($attempt = 0; $attempt -lt 1200; $attempt += 1) {
|
|
248
|
+
if ([IO.File]::Exists($grantPath)) {
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
Start-Sleep -Milliseconds 25
|
|
252
|
+
}
|
|
253
|
+
throw "Timed out waiting for the provider start grant."
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function Start-ForgeOutputCopies([Diagnostics.Process]$child) {
|
|
257
|
+
return @(
|
|
258
|
+
$child.StandardOutput.BaseStream.CopyToAsync([Console]::OpenStandardOutput()),
|
|
259
|
+
$child.StandardError.BaseStream.CopyToAsync([Console]::OpenStandardError())
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function Write-ForgeStandardInput([Diagnostics.Process]$child, [string]$inputText) {
|
|
264
|
+
if ($inputText.Length -gt 0) {
|
|
265
|
+
$child.StandardInput.Write($inputText)
|
|
266
|
+
}
|
|
267
|
+
$child.StandardInput.Close()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if ($Mode -eq "Inner") {
|
|
271
|
+
if ([string]::IsNullOrWhiteSpace($GateName)) {
|
|
272
|
+
throw "The inner supervisor requires a gate name."
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
$inputText = [Console]::In.ReadToEnd()
|
|
276
|
+
$request = Read-ForgeSupervisorPayload
|
|
277
|
+
$gate = [Threading.EventWaitHandle]::OpenExisting($GateName)
|
|
278
|
+
try {
|
|
279
|
+
if (-not $gate.WaitOne(30000)) {
|
|
280
|
+
throw "Timed out waiting for Windows Job Object assignment."
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
$gate.Dispose()
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
$provider = New-ForgeChildProcess ([string]$request.command) @($request.args) ([string]$request.cwd) $true
|
|
288
|
+
Write-ForgeProviderReady $request $provider
|
|
289
|
+
$copies = Start-ForgeOutputCopies $provider
|
|
290
|
+
Wait-ForgeProviderStartGrant $request
|
|
291
|
+
Write-ForgeStandardInput $provider $inputText
|
|
292
|
+
$provider.WaitForExit()
|
|
293
|
+
if (-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]$copies, 1000)) {
|
|
294
|
+
$provider.StandardOutput.Dispose()
|
|
295
|
+
$provider.StandardError.Dispose()
|
|
296
|
+
}
|
|
297
|
+
exit $provider.ExitCode
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
$inputText = [Console]::In.ReadToEnd()
|
|
301
|
+
$request = Read-ForgeSupervisorPayload
|
|
302
|
+
$env:FORGE_SUPERVISOR_ROOT_PID = [string]$PID
|
|
303
|
+
$env:FORGE_SUPERVISOR_ROOT_IDENTITY = "win32:$([Diagnostics.Process]::GetCurrentProcess().StartTime.ToUniversalTime().Ticks)"
|
|
304
|
+
$parent = [Diagnostics.Process]::GetProcessById([int]$request.parentPid)
|
|
305
|
+
$jobName = "Local\Forge-$([string]$request.supervisorId)"
|
|
306
|
+
$job = [ForgeWindowsJob]::CreateKillOnCloseJob($jobName)
|
|
307
|
+
$gateNameValue = "Local\ForgeGate-$([Guid]::NewGuid().ToString('N'))"
|
|
308
|
+
$createdNew = $false
|
|
309
|
+
$gate = New-Object Threading.EventWaitHandle($false, [Threading.EventResetMode]::ManualReset, $gateNameValue, [ref]$createdNew)
|
|
310
|
+
$inner = $null
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
$powerShell = Join-Path $PSHOME "powershell.exe"
|
|
314
|
+
$innerArguments = @(
|
|
315
|
+
"-NoLogo",
|
|
316
|
+
"-NoProfile",
|
|
317
|
+
"-NonInteractive",
|
|
318
|
+
"-ExecutionPolicy",
|
|
319
|
+
"Bypass",
|
|
320
|
+
"-File",
|
|
321
|
+
$PSCommandPath,
|
|
322
|
+
"-Mode",
|
|
323
|
+
"Inner",
|
|
324
|
+
"-PayloadPath",
|
|
325
|
+
$PayloadPath,
|
|
326
|
+
"-GateName",
|
|
327
|
+
$gateNameValue
|
|
328
|
+
)
|
|
329
|
+
$inner = New-ForgeChildProcess $powerShell $innerArguments ([string]$request.cwd) $true
|
|
330
|
+
if (-not [ForgeWindowsJob]::AssignProcessToJobObject($job, $inner.Handle)) {
|
|
331
|
+
throw (New-Object System.ComponentModel.Win32Exception([Runtime.InteropServices.Marshal]::GetLastWin32Error(), "AssignProcessToJobObject failed."))
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
$copies = Start-ForgeOutputCopies $inner
|
|
335
|
+
$gate.Set() | Out-Null
|
|
336
|
+
Write-ForgeStandardInput $inner $inputText
|
|
337
|
+
while (-not $inner.WaitForExit(250)) {
|
|
338
|
+
$parent.Refresh()
|
|
339
|
+
if ($parent.HasExited) {
|
|
340
|
+
[ForgeWindowsJob]::TerminateJobObject($job, 125) | Out-Null
|
|
341
|
+
exit 125
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
$exitCode = $inner.ExitCode
|
|
346
|
+
[ForgeWindowsJob]::TerminateJobObject($job, 1) | Out-Null
|
|
347
|
+
[Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]$copies, 2000) | Out-Null
|
|
348
|
+
exit $exitCode
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
if ($inner -and -not $inner.HasExited) {
|
|
352
|
+
[ForgeWindowsJob]::TerminateJobObject($job, 1) | Out-Null
|
|
353
|
+
}
|
|
354
|
+
$gate.Dispose()
|
|
355
|
+
[ForgeWindowsJob]::CloseHandle($job) | Out-Null
|
|
356
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[Parameter(Mandatory = $true)]
|
|
3
|
+
[int]$RootProcessId,
|
|
4
|
+
[Parameter(Mandatory = $true)]
|
|
5
|
+
[string]$ListenerProcessIds
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
$ErrorActionPreference = "Stop"
|
|
9
|
+
$parents = @{}
|
|
10
|
+
Get-CimInstance Win32_Process | ForEach-Object {
|
|
11
|
+
$parents[[int]$_.ProcessId] = [int]$_.ParentProcessId
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
$results = @()
|
|
15
|
+
foreach ($listenerText in $ListenerProcessIds.Split(',')) {
|
|
16
|
+
$current = [int]$listenerText
|
|
17
|
+
$visited = @{}
|
|
18
|
+
$owned = $false
|
|
19
|
+
while ($current -gt 0 -and -not $visited.ContainsKey($current)) {
|
|
20
|
+
if ($current -eq $RootProcessId) {
|
|
21
|
+
$owned = $true
|
|
22
|
+
break
|
|
23
|
+
}
|
|
24
|
+
$visited[$current] = $true
|
|
25
|
+
if (-not $parents.ContainsKey($current)) {
|
|
26
|
+
break
|
|
27
|
+
}
|
|
28
|
+
$current = [int]$parents[$current]
|
|
29
|
+
}
|
|
30
|
+
$results += $owned
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
ConvertTo-Json -Compress -InputObject @($results)
|