bopodev-api 0.1.15 → 0.1.17
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/package.json +4 -4
- package/src/app.ts +57 -1
- package/src/context.ts +3 -0
- package/src/lib/agent-config.ts +10 -1
- package/src/lib/git-runtime.ts +447 -0
- package/src/lib/instance-paths.ts +75 -10
- package/src/lib/workspace-policy.ts +153 -10
- package/src/middleware/request-actor.ts +67 -2
- package/src/realtime/hub.ts +31 -2
- package/src/routes/agents.ts +146 -107
- package/src/routes/auth.ts +54 -0
- package/src/routes/companies.ts +2 -0
- package/src/routes/governance.ts +8 -0
- package/src/routes/issues.ts +23 -10
- package/src/routes/projects.ts +361 -63
- package/src/routes/templates.ts +439 -0
- package/src/scripts/backfill-project-workspaces.ts +61 -24
- package/src/scripts/db-init.ts +7 -1
- package/src/scripts/onboard-seed.ts +199 -64
- package/src/security/actor-token.ts +133 -0
- package/src/security/deployment-mode.ts +73 -0
- package/src/server.ts +72 -4
- package/src/services/governance-service.ts +122 -15
- package/src/services/heartbeat-service.ts +89 -20
- package/src/services/plugin-runtime.ts +2 -2
- package/src/services/template-apply-service.ts +138 -0
- package/src/services/template-catalog.ts +487 -0
- package/src/services/template-preview-service.ts +78 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bopodev-api",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
"nanoid": "^5.1.5",
|
|
18
18
|
"ws": "^8.19.0",
|
|
19
19
|
"zod": "^4.1.5",
|
|
20
|
-
"bopodev-agent-sdk": "0.1.
|
|
21
|
-
"bopodev-
|
|
22
|
-
"bopodev-
|
|
20
|
+
"bopodev-agent-sdk": "0.1.17",
|
|
21
|
+
"bopodev-db": "0.1.17",
|
|
22
|
+
"bopodev-contracts": "0.1.17"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/cors": "^2.8.19",
|
package/src/app.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { RepositoryValidationError } from "bopodev-db";
|
|
|
6
6
|
import { nanoid } from "nanoid";
|
|
7
7
|
import type { AppContext } from "./context";
|
|
8
8
|
import { createAgentsRouter } from "./routes/agents";
|
|
9
|
+
import { createAuthRouter } from "./routes/auth";
|
|
9
10
|
import { createCompaniesRouter } from "./routes/companies";
|
|
10
11
|
import { createGoalsRouter } from "./routes/goals";
|
|
11
12
|
import { createGovernanceRouter } from "./routes/governance";
|
|
@@ -14,12 +15,40 @@ import { createIssuesRouter } from "./routes/issues";
|
|
|
14
15
|
import { createObservabilityRouter } from "./routes/observability";
|
|
15
16
|
import { createProjectsRouter } from "./routes/projects";
|
|
16
17
|
import { createPluginsRouter } from "./routes/plugins";
|
|
18
|
+
import { createTemplatesRouter } from "./routes/templates";
|
|
17
19
|
import { sendError } from "./http";
|
|
18
20
|
import { attachRequestActor } from "./middleware/request-actor";
|
|
21
|
+
import { resolveAllowedOrigins, resolveDeploymentMode } from "./security/deployment-mode";
|
|
19
22
|
|
|
20
23
|
export function createApp(ctx: AppContext) {
|
|
21
24
|
const app = express();
|
|
22
|
-
|
|
25
|
+
const deploymentMode = ctx.deploymentMode ?? resolveDeploymentMode();
|
|
26
|
+
const allowedOrigins = ctx.allowedOrigins ?? resolveAllowedOrigins(deploymentMode);
|
|
27
|
+
app.use(
|
|
28
|
+
cors({
|
|
29
|
+
origin(origin, callback) {
|
|
30
|
+
if (!origin) {
|
|
31
|
+
callback(null, true);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (
|
|
35
|
+
deploymentMode === "local" &&
|
|
36
|
+
(origin.startsWith("http://localhost:") || origin.startsWith("http://127.0.0.1:"))
|
|
37
|
+
) {
|
|
38
|
+
callback(null, true);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (allowedOrigins.includes(origin)) {
|
|
42
|
+
callback(null, true);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
callback(new Error(`CORS origin denied: ${origin}`));
|
|
46
|
+
},
|
|
47
|
+
credentials: true,
|
|
48
|
+
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
49
|
+
allowedHeaders: ["content-type", "x-company-id", "authorization", "x-client-trace-id", "x-bopo-actor-token"]
|
|
50
|
+
})
|
|
51
|
+
);
|
|
23
52
|
app.use(express.json());
|
|
24
53
|
app.use(attachRequestActor);
|
|
25
54
|
|
|
@@ -29,6 +58,27 @@ export function createApp(ctx: AppContext) {
|
|
|
29
58
|
res.setHeader("x-request-id", requestId);
|
|
30
59
|
next();
|
|
31
60
|
});
|
|
61
|
+
const logApiRequests = process.env.BOPO_LOG_API_REQUESTS !== "0";
|
|
62
|
+
if (logApiRequests) {
|
|
63
|
+
app.use((req, res, next) => {
|
|
64
|
+
if (req.path === "/health") {
|
|
65
|
+
next();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const method = req.method.toUpperCase();
|
|
69
|
+
if (!isCrudMethod(method)) {
|
|
70
|
+
next();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const startedAt = Date.now();
|
|
74
|
+
res.on("finish", () => {
|
|
75
|
+
const elapsedMs = Date.now() - startedAt;
|
|
76
|
+
const timestamp = new Date().toTimeString().slice(0, 8);
|
|
77
|
+
process.stderr.write(`[${timestamp}] INFO: ${method} ${req.originalUrl} ${res.statusCode} ${elapsedMs}ms\n`);
|
|
78
|
+
});
|
|
79
|
+
next();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
32
82
|
|
|
33
83
|
app.get("/health", async (_req, res) => {
|
|
34
84
|
let dbReady = false;
|
|
@@ -55,6 +105,7 @@ export function createApp(ctx: AppContext) {
|
|
|
55
105
|
});
|
|
56
106
|
});
|
|
57
107
|
|
|
108
|
+
app.use("/auth", createAuthRouter(ctx));
|
|
58
109
|
app.use("/companies", createCompaniesRouter(ctx));
|
|
59
110
|
app.use("/projects", createProjectsRouter(ctx));
|
|
60
111
|
app.use("/issues", createIssuesRouter(ctx));
|
|
@@ -64,6 +115,7 @@ export function createApp(ctx: AppContext) {
|
|
|
64
115
|
app.use("/heartbeats", createHeartbeatRouter(ctx));
|
|
65
116
|
app.use("/observability", createObservabilityRouter(ctx));
|
|
66
117
|
app.use("/plugins", createPluginsRouter(ctx));
|
|
118
|
+
app.use("/templates", createTemplatesRouter(ctx));
|
|
67
119
|
|
|
68
120
|
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
|
69
121
|
if (error instanceof RepositoryValidationError) {
|
|
@@ -76,3 +128,7 @@ export function createApp(ctx: AppContext) {
|
|
|
76
128
|
|
|
77
129
|
return app;
|
|
78
130
|
}
|
|
131
|
+
|
|
132
|
+
function isCrudMethod(method: string) {
|
|
133
|
+
return method === "GET" || method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
|
134
|
+
}
|
package/src/context.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { BopoDb } from "bopodev-db";
|
|
2
2
|
import type { RealtimeHub } from "./realtime/hub";
|
|
3
|
+
import type { DeploymentMode } from "./security/deployment-mode";
|
|
3
4
|
|
|
4
5
|
export interface AppContext {
|
|
5
6
|
db: BopoDb;
|
|
7
|
+
deploymentMode?: DeploymentMode;
|
|
8
|
+
allowedOrigins?: string[];
|
|
6
9
|
getRuntimeHealth?: () => Promise<Record<string, unknown>>;
|
|
7
10
|
realtimeHub?: RealtimeHub;
|
|
8
11
|
}
|
package/src/lib/agent-config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AgentRuntimeConfigSchema, type AgentRuntimeConfig, type ThinkingEffort } from "bopodev-contracts";
|
|
2
|
+
import { normalizeAbsolutePath } from "./instance-paths";
|
|
2
3
|
|
|
3
4
|
export type LegacyRuntimeFields = {
|
|
4
5
|
runtimeCommand?: string;
|
|
@@ -120,7 +121,7 @@ export function normalizeRuntimeConfig(input: {
|
|
|
120
121
|
return {
|
|
121
122
|
runtimeCommand: parsed.runtimeCommand?.trim() || undefined,
|
|
122
123
|
runtimeArgs: parsed.runtimeArgs ?? [],
|
|
123
|
-
runtimeCwd: parsed.runtimeCwd
|
|
124
|
+
runtimeCwd: normalizeRuntimeCwd(parsed.runtimeCwd, input.defaultRuntimeCwd),
|
|
124
125
|
runtimeEnv: parsed.runtimeEnv ?? {},
|
|
125
126
|
runtimeModel: parsed.runtimeModel?.trim() || undefined,
|
|
126
127
|
runtimeThinkingEffort: parsed.runtimeThinkingEffort ?? "auto",
|
|
@@ -134,6 +135,14 @@ export function normalizeRuntimeConfig(input: {
|
|
|
134
135
|
};
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
function normalizeRuntimeCwd(runtimeCwd: string | undefined, defaultRuntimeCwd: string | undefined) {
|
|
139
|
+
const selected = runtimeCwd?.trim() || defaultRuntimeCwd || undefined;
|
|
140
|
+
if (!selected) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
return normalizeAbsolutePath(selected, { requireAbsoluteInput: true });
|
|
144
|
+
}
|
|
145
|
+
|
|
137
146
|
export function parseRuntimeConfigFromAgentRow(agent: Record<string, unknown>): NormalizedRuntimeConfig {
|
|
138
147
|
const fallback = parseRuntimeFromStateBlob(agent.stateBlob);
|
|
139
148
|
const runtimeArgs = parseStringArray(agent.runtimeArgsJson) ?? fallback.args ?? [];
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { mkdir, readdir, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import {
|
|
5
|
+
assertPathInsideCompanyWorkspaceRoot,
|
|
6
|
+
assertPathInsidePath,
|
|
7
|
+
isInsidePath,
|
|
8
|
+
normalizeAbsolutePath,
|
|
9
|
+
resolveAgentProjectWorktreeRootPath,
|
|
10
|
+
resolveProjectWorkspacePath
|
|
11
|
+
} from "./instance-paths";
|
|
12
|
+
import type { ProjectExecutionWorkspacePolicy } from "./workspace-policy";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_GIT_TIMEOUT_MS = 30_000;
|
|
15
|
+
const MAX_OUTPUT_CHARS = 4_096;
|
|
16
|
+
|
|
17
|
+
export class GitRuntimeError extends Error {
|
|
18
|
+
readonly code:
|
|
19
|
+
| "git_unavailable"
|
|
20
|
+
| "git_failed"
|
|
21
|
+
| "auth_missing"
|
|
22
|
+
| "policy_violation"
|
|
23
|
+
| "invalid_repo_url"
|
|
24
|
+
| "timeout";
|
|
25
|
+
readonly details: Record<string, unknown>;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
code:
|
|
29
|
+
| "git_unavailable"
|
|
30
|
+
| "git_failed"
|
|
31
|
+
| "auth_missing"
|
|
32
|
+
| "policy_violation"
|
|
33
|
+
| "invalid_repo_url"
|
|
34
|
+
| "timeout",
|
|
35
|
+
message: string,
|
|
36
|
+
details: Record<string, unknown> = {}
|
|
37
|
+
) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "GitRuntimeError";
|
|
40
|
+
this.code = code;
|
|
41
|
+
this.details = details;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type GitAuthResolution = {
|
|
46
|
+
mode: "host" | "env_token";
|
|
47
|
+
extraArgs: string[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type GitCommandResult = {
|
|
51
|
+
stdout: string;
|
|
52
|
+
stderr: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export async function bootstrapRepositoryWorkspace(input: {
|
|
56
|
+
companyId: string;
|
|
57
|
+
projectId: string;
|
|
58
|
+
cwd: string | null | undefined;
|
|
59
|
+
repoUrl: string;
|
|
60
|
+
repoRef?: string | null;
|
|
61
|
+
policy?: ProjectExecutionWorkspacePolicy | null;
|
|
62
|
+
runtimeEnv?: Record<string, string>;
|
|
63
|
+
timeoutMs?: number;
|
|
64
|
+
}) {
|
|
65
|
+
const repoUrl = input.repoUrl.trim();
|
|
66
|
+
if (!repoUrl) {
|
|
67
|
+
throw new GitRuntimeError("invalid_repo_url", "Project workspace repoUrl is empty.");
|
|
68
|
+
}
|
|
69
|
+
enforceRemoteAllowlist(repoUrl, input.policy);
|
|
70
|
+
const targetCwd = normalizeAbsolutePath(
|
|
71
|
+
input.cwd?.trim() || resolveProjectWorkspacePath(input.companyId, input.projectId)
|
|
72
|
+
);
|
|
73
|
+
assertPathInsideCompanyWorkspaceRoot(input.companyId, targetCwd, "project workspace cwd");
|
|
74
|
+
await mkdir(targetCwd, { recursive: true });
|
|
75
|
+
const auth = resolveGitAuth({
|
|
76
|
+
policy: input.policy,
|
|
77
|
+
runtimeEnv: input.runtimeEnv ?? {},
|
|
78
|
+
repoUrl
|
|
79
|
+
});
|
|
80
|
+
const timeoutMs = sanitizeTimeoutMs(input.timeoutMs);
|
|
81
|
+
const gitDirPath = join(targetCwd, ".git");
|
|
82
|
+
const hasGitDir = await pathExists(gitDirPath);
|
|
83
|
+
const actions: string[] = [];
|
|
84
|
+
if (!hasGitDir) {
|
|
85
|
+
await runGit(["clone", repoUrl, targetCwd], {
|
|
86
|
+
extraArgs: auth.extraArgs,
|
|
87
|
+
timeoutMs
|
|
88
|
+
});
|
|
89
|
+
actions.push("clone");
|
|
90
|
+
} else {
|
|
91
|
+
actions.push("reuse");
|
|
92
|
+
}
|
|
93
|
+
await runGit(["remote", "set-url", "origin", repoUrl], {
|
|
94
|
+
cwd: targetCwd,
|
|
95
|
+
extraArgs: auth.extraArgs,
|
|
96
|
+
timeoutMs
|
|
97
|
+
});
|
|
98
|
+
await runGit(["fetch", "origin", "--prune"], {
|
|
99
|
+
cwd: targetCwd,
|
|
100
|
+
extraArgs: auth.extraArgs,
|
|
101
|
+
timeoutMs
|
|
102
|
+
});
|
|
103
|
+
actions.push("fetch");
|
|
104
|
+
const targetRef = (input.repoRef ?? "").trim() || (await resolveDefaultRemoteHead(targetCwd, auth.extraArgs, timeoutMs));
|
|
105
|
+
if (targetRef) {
|
|
106
|
+
await checkoutRef(targetCwd, targetRef, auth.extraArgs, timeoutMs);
|
|
107
|
+
actions.push(`checkout:${targetRef}`);
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
cwd: targetCwd,
|
|
111
|
+
authMode: auth.mode,
|
|
112
|
+
resolvedRef: targetRef || null,
|
|
113
|
+
actions
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function ensureIsolatedGitWorktree(input: {
|
|
118
|
+
companyId: string;
|
|
119
|
+
repoCwd: string;
|
|
120
|
+
projectId: string;
|
|
121
|
+
agentId: string;
|
|
122
|
+
issueId?: string | null;
|
|
123
|
+
repoRef?: string | null;
|
|
124
|
+
policy?: ProjectExecutionWorkspacePolicy | null;
|
|
125
|
+
timeoutMs?: number;
|
|
126
|
+
}) {
|
|
127
|
+
const timeoutMs = sanitizeTimeoutMs(input.timeoutMs);
|
|
128
|
+
const normalizedRepoCwd = normalizeAbsolutePath(input.repoCwd, { requireAbsoluteInput: true });
|
|
129
|
+
assertPathInsideCompanyWorkspaceRoot(input.companyId, normalizedRepoCwd, "repo workspace cwd");
|
|
130
|
+
const strategy = input.policy?.strategy;
|
|
131
|
+
const branchPrefix = (strategy?.branchPrefix?.trim() || "bopo").replace(/[^a-zA-Z0-9/_-]/g, "-");
|
|
132
|
+
enforceBranchPrefixAllowlist(branchPrefix, input.policy);
|
|
133
|
+
const issuePart = (input.issueId?.trim() || "run").replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
134
|
+
const agentPart = input.agentId.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
135
|
+
const projectPart = input.projectId.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
136
|
+
const branch = `${branchPrefix}/${projectPart}/${agentPart}/${issuePart}`;
|
|
137
|
+
const rootDir = strategy?.rootDir?.trim()
|
|
138
|
+
? assertPathInsideCompanyWorkspaceRoot(
|
|
139
|
+
input.companyId,
|
|
140
|
+
normalizeAbsolutePath(strategy.rootDir, { requireAbsoluteInput: true }),
|
|
141
|
+
"execution workspace strategy.rootDir"
|
|
142
|
+
)
|
|
143
|
+
: resolveAgentProjectWorktreeRootPath(input.companyId, input.agentId, input.projectId);
|
|
144
|
+
await mkdir(rootDir, { recursive: true });
|
|
145
|
+
await cleanupStaleWorktrees({ rootDir, ttlMs: resolveWorktreeTtlMs() });
|
|
146
|
+
const targetPath = join(rootDir, `${projectPart}-${agentPart}-${issuePart}`);
|
|
147
|
+
assertPathInsidePath(rootDir, targetPath, "isolated worktree path");
|
|
148
|
+
const hasWorktree = await pathExists(targetPath);
|
|
149
|
+
if (!hasWorktree) {
|
|
150
|
+
const baseRef = (input.repoRef ?? "").trim() || "HEAD";
|
|
151
|
+
await runGit(["worktree", "add", "-B", branch, targetPath, baseRef], {
|
|
152
|
+
cwd: normalizedRepoCwd,
|
|
153
|
+
timeoutMs
|
|
154
|
+
});
|
|
155
|
+
} else {
|
|
156
|
+
await runGit(["-C", targetPath, "checkout", branch], { timeoutMs });
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
cwd: targetPath,
|
|
160
|
+
branch,
|
|
161
|
+
rootDir
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function cleanupStaleWorktrees(input: { rootDir: string; ttlMs: number }) {
|
|
166
|
+
if (input.ttlMs <= 0) {
|
|
167
|
+
return { removed: 0 };
|
|
168
|
+
}
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
const rootDir = normalizeAbsolutePath(input.rootDir, { requireAbsoluteInput: true });
|
|
171
|
+
let removed = 0;
|
|
172
|
+
let entries: string[] = [];
|
|
173
|
+
try {
|
|
174
|
+
entries = await readdir(rootDir);
|
|
175
|
+
} catch {
|
|
176
|
+
return { removed: 0 };
|
|
177
|
+
}
|
|
178
|
+
for (const entry of entries) {
|
|
179
|
+
const absolute = join(rootDir, entry);
|
|
180
|
+
if (!isInsidePath(rootDir, absolute)) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const entryStat = await stat(absolute);
|
|
185
|
+
if (!entryStat.isDirectory()) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (now - entryStat.mtimeMs < input.ttlMs) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
await rm(absolute, { recursive: true, force: true });
|
|
192
|
+
removed += 1;
|
|
193
|
+
} catch {
|
|
194
|
+
// Best effort cleanup only.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { removed };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function resolveGitAuth(input: {
|
|
201
|
+
policy?: ProjectExecutionWorkspacePolicy | null;
|
|
202
|
+
runtimeEnv: Record<string, string>;
|
|
203
|
+
repoUrl: string;
|
|
204
|
+
}): GitAuthResolution {
|
|
205
|
+
const configured = input.policy?.credentials;
|
|
206
|
+
const mode = configured?.mode ?? "host";
|
|
207
|
+
if (mode === "host") {
|
|
208
|
+
return { mode: "host", extraArgs: [] };
|
|
209
|
+
}
|
|
210
|
+
const tokenEnvVar = configured?.tokenEnvVar?.trim();
|
|
211
|
+
if (!tokenEnvVar) {
|
|
212
|
+
throw new GitRuntimeError("auth_missing", "Workspace git auth policy requires credentials.tokenEnvVar.");
|
|
213
|
+
}
|
|
214
|
+
const token = input.runtimeEnv[tokenEnvVar] ?? process.env[tokenEnvVar];
|
|
215
|
+
const normalizedToken = token?.trim();
|
|
216
|
+
if (!normalizedToken) {
|
|
217
|
+
throw new GitRuntimeError("auth_missing", `Git token env var '${tokenEnvVar}' is missing for repository access.`);
|
|
218
|
+
}
|
|
219
|
+
const username = configured?.username?.trim() || "x-access-token";
|
|
220
|
+
if (!isHttpsRepoUrl(input.repoUrl)) {
|
|
221
|
+
return { mode: "env_token", extraArgs: [] };
|
|
222
|
+
}
|
|
223
|
+
const basicHeader = Buffer.from(`${username}:${normalizedToken}`, "utf8").toString("base64");
|
|
224
|
+
return {
|
|
225
|
+
mode: "env_token",
|
|
226
|
+
extraArgs: ["-c", `http.extraHeader=Authorization: Basic ${basicHeader}`]
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function enforceRemoteAllowlist(repoUrl: string, policy?: ProjectExecutionWorkspacePolicy | null) {
|
|
231
|
+
const allowRemotes = policy?.allowRemotes ?? null;
|
|
232
|
+
if (!allowRemotes || allowRemotes.length === 0) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const identity = parseRepositoryIdentity(repoUrl);
|
|
236
|
+
if (!identity) {
|
|
237
|
+
throw new GitRuntimeError("invalid_repo_url", `Repository '${repoUrl}' is not a recognized git URL.`);
|
|
238
|
+
}
|
|
239
|
+
const allowed = allowRemotes.some((candidate) => {
|
|
240
|
+
return matchesAllowRemoteCandidate(identity, candidate);
|
|
241
|
+
});
|
|
242
|
+
if (!allowed) {
|
|
243
|
+
throw new GitRuntimeError("policy_violation", `Repository '${repoUrl}' is not in execution allowRemotes policy.`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function enforceBranchPrefixAllowlist(prefix: string, policy?: ProjectExecutionWorkspacePolicy | null) {
|
|
248
|
+
const allowBranchPrefixes = policy?.allowBranchPrefixes ?? null;
|
|
249
|
+
if (!allowBranchPrefixes || allowBranchPrefixes.length === 0) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const allowed = allowBranchPrefixes.some((candidate) => prefix.startsWith(candidate.trim()));
|
|
253
|
+
if (!allowed) {
|
|
254
|
+
throw new GitRuntimeError(
|
|
255
|
+
"policy_violation",
|
|
256
|
+
`Branch prefix '${prefix}' is not allowed by execution policy allowBranchPrefixes.`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function checkoutRef(cwd: string, ref: string, extraArgs: string[], timeoutMs: number) {
|
|
262
|
+
await runGit(["checkout", ref], { cwd, extraArgs, timeoutMs });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function resolveDefaultRemoteHead(cwd: string, extraArgs: string[], timeoutMs: number) {
|
|
266
|
+
const symbolic = await runGit(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], {
|
|
267
|
+
cwd,
|
|
268
|
+
extraArgs,
|
|
269
|
+
timeoutMs,
|
|
270
|
+
allowFailure: true
|
|
271
|
+
});
|
|
272
|
+
const symbolicRef = symbolic.stdout.trim();
|
|
273
|
+
if (symbolicRef.startsWith("origin/")) {
|
|
274
|
+
return symbolicRef.slice("origin/".length);
|
|
275
|
+
}
|
|
276
|
+
for (const fallbackRef of ["main", "master"]) {
|
|
277
|
+
const probe = await runGit(["rev-parse", "--verify", `origin/${fallbackRef}`], {
|
|
278
|
+
cwd,
|
|
279
|
+
extraArgs,
|
|
280
|
+
timeoutMs,
|
|
281
|
+
allowFailure: true
|
|
282
|
+
});
|
|
283
|
+
if (probe.exitCode === 0) {
|
|
284
|
+
return fallbackRef;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return "";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function runGit(
|
|
291
|
+
args: string[],
|
|
292
|
+
options?: {
|
|
293
|
+
cwd?: string;
|
|
294
|
+
extraArgs?: string[];
|
|
295
|
+
timeoutMs?: number;
|
|
296
|
+
allowFailure?: boolean;
|
|
297
|
+
}
|
|
298
|
+
): Promise<GitCommandResult & { exitCode: number }> {
|
|
299
|
+
const timeoutMs = sanitizeTimeoutMs(options?.timeoutMs);
|
|
300
|
+
const fullArgs = [...(options?.extraArgs ?? []), ...args];
|
|
301
|
+
return new Promise((resolve, reject) => {
|
|
302
|
+
const child = spawn("git", fullArgs, {
|
|
303
|
+
cwd: options?.cwd,
|
|
304
|
+
env: process.env
|
|
305
|
+
});
|
|
306
|
+
let stdout = "";
|
|
307
|
+
let stderr = "";
|
|
308
|
+
let timeout: NodeJS.Timeout | null = setTimeout(() => {
|
|
309
|
+
child.kill("SIGTERM");
|
|
310
|
+
timeout = null;
|
|
311
|
+
reject(new GitRuntimeError("timeout", `git command timed out after ${timeoutMs}ms`, { args: redactArgs(fullArgs) }));
|
|
312
|
+
}, timeoutMs);
|
|
313
|
+
child.stdout.on("data", (chunk) => {
|
|
314
|
+
stdout = truncateOutput(stdout + String(chunk));
|
|
315
|
+
});
|
|
316
|
+
child.stderr.on("data", (chunk) => {
|
|
317
|
+
stderr = truncateOutput(stderr + String(chunk));
|
|
318
|
+
});
|
|
319
|
+
child.on("error", (error) => {
|
|
320
|
+
if (timeout) {
|
|
321
|
+
clearTimeout(timeout);
|
|
322
|
+
}
|
|
323
|
+
const message = String(error);
|
|
324
|
+
reject(new GitRuntimeError("git_unavailable", "Unable to execute git binary.", { message }));
|
|
325
|
+
});
|
|
326
|
+
child.on("close", (code) => {
|
|
327
|
+
if (timeout) {
|
|
328
|
+
clearTimeout(timeout);
|
|
329
|
+
}
|
|
330
|
+
const exitCode = code ?? 1;
|
|
331
|
+
if (exitCode !== 0 && !options?.allowFailure) {
|
|
332
|
+
reject(
|
|
333
|
+
new GitRuntimeError("git_failed", "git command failed.", {
|
|
334
|
+
exitCode,
|
|
335
|
+
args: redactArgs(fullArgs),
|
|
336
|
+
stderr: redactSensitive(stderr)
|
|
337
|
+
})
|
|
338
|
+
);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
resolve({ stdout, stderr, exitCode });
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function sanitizeTimeoutMs(timeoutMs: number | undefined) {
|
|
347
|
+
const parsed = Number(timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS);
|
|
348
|
+
if (!Number.isFinite(parsed) || parsed < 1_000) {
|
|
349
|
+
return DEFAULT_GIT_TIMEOUT_MS;
|
|
350
|
+
}
|
|
351
|
+
return Math.floor(parsed);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function resolveWorktreeTtlMs() {
|
|
355
|
+
const parsedMinutes = Number(process.env.BOPO_GIT_WORKTREE_TTL_MINUTES ?? "240");
|
|
356
|
+
if (!Number.isFinite(parsedMinutes) || parsedMinutes < 5) {
|
|
357
|
+
return 240 * 60_000;
|
|
358
|
+
}
|
|
359
|
+
return Math.floor(parsedMinutes * 60_000);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function truncateOutput(value: string) {
|
|
363
|
+
return value.length > MAX_OUTPUT_CHARS ? value.slice(value.length - MAX_OUTPUT_CHARS) : value;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function redactArgs(args: string[]) {
|
|
367
|
+
return args.map((value) => redactSensitive(value));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function redactSensitive(value: string) {
|
|
371
|
+
return value
|
|
372
|
+
.replace(/(authorization:\s*basic\s+)[a-z0-9+/=]+/gi, "$1[REDACTED]")
|
|
373
|
+
.replace(/(authorization:\s*bearer\s+)[^\s]+/gi, "$1[REDACTED]");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function isHttpsRepoUrl(value: string) {
|
|
377
|
+
try {
|
|
378
|
+
const parsed = new URL(value);
|
|
379
|
+
return parsed.protocol === "https:";
|
|
380
|
+
} catch {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
type RepositoryIdentity = {
|
|
386
|
+
host: string;
|
|
387
|
+
path: string;
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
function parseRepositoryIdentity(repoUrl: string): RepositoryIdentity | null {
|
|
391
|
+
const trimmed = repoUrl.trim();
|
|
392
|
+
if (!trimmed) {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
const scpLike = trimmed.match(/^(?:[^@]+@)?([^:\/]+):(.+)$/);
|
|
396
|
+
if (scpLike) {
|
|
397
|
+
return {
|
|
398
|
+
host: scpLike[1]!.toLowerCase(),
|
|
399
|
+
path: normalizeRepoPath(scpLike[2]!)
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
const parsed = new URL(trimmed);
|
|
404
|
+
const host = parsed.hostname.toLowerCase();
|
|
405
|
+
if (!host) {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
const path = normalizeRepoPath(parsed.pathname);
|
|
409
|
+
return { host, path };
|
|
410
|
+
} catch {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function normalizeRepoPath(path: string) {
|
|
416
|
+
return path
|
|
417
|
+
.replace(/^\/+/, "")
|
|
418
|
+
.replace(/\.git$/i, "")
|
|
419
|
+
.toLowerCase();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function matchesAllowRemoteCandidate(identity: RepositoryIdentity, candidate: string) {
|
|
423
|
+
const normalizedCandidate = candidate.trim().toLowerCase();
|
|
424
|
+
if (!normalizedCandidate) {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
const hostPathMatch = normalizedCandidate.match(/^([^/]+)\/(.+)$/);
|
|
428
|
+
if (hostPathMatch) {
|
|
429
|
+
const candidateHost = hostPathMatch[1]!;
|
|
430
|
+
const candidatePath = normalizeRepoPath(hostPathMatch[2]!);
|
|
431
|
+
return identity.host === candidateHost && (identity.path === candidatePath || identity.path.startsWith(`${candidatePath}/`));
|
|
432
|
+
}
|
|
433
|
+
if (normalizedCandidate.includes(".") || normalizedCandidate.includes(":")) {
|
|
434
|
+
return identity.host === normalizedCandidate;
|
|
435
|
+
}
|
|
436
|
+
const candidatePathOnly = normalizeRepoPath(normalizedCandidate);
|
|
437
|
+
return identity.path === candidatePathOnly || identity.path.startsWith(`${candidatePathOnly}/`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function pathExists(path: string) {
|
|
441
|
+
try {
|
|
442
|
+
await stat(path);
|
|
443
|
+
return true;
|
|
444
|
+
} catch {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
}
|