@fitlab-ai/agent-infra 0.9.0 → 0.9.1
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 +6 -1
- package/README.zh-CN.md +5 -1
- package/dist/bin/internal-cli.js +5 -0
- package/dist/lib/internal/platform-release-notes.js +53 -0
- package/dist/lib/internal/release-workflow.js +105 -2
- package/dist/lib/platform/github-release-notes.js +96 -0
- package/dist/lib/platform/issues.js +4 -2
- package/dist/lib/platform/merged-pr-equivalence.js +54 -0
- package/dist/lib/platform/pr-checks.js +6 -6
- package/dist/lib/platform/pull-requests.js +3 -1
- package/dist/lib/platform/release-notes.js +143 -0
- package/dist/lib/platform/verification-required.js +29 -5
- package/dist/lib/sandbox/build-proxy.js +63 -0
- package/dist/lib/sandbox/commands/create.js +57 -9
- package/dist/lib/sandbox/commands/rebuild.js +24 -7
- package/dist/lib/sandbox/image-build.js +2 -1
- package/dist/lib/sandbox/runtimes/ai-tools.dockerfile +24 -3
- package/dist/lib/sandbox/runtimes/base.dockerfile +8 -1
- package/dist/lib/sandbox/runtimes/java17.dockerfile +8 -1
- package/dist/lib/sandbox/runtimes/java21.dockerfile +8 -1
- package/dist/lib/sandbox/runtimes/node20.dockerfile +8 -1
- package/dist/lib/sandbox/runtimes/node22.dockerfile +8 -1
- package/dist/lib/sandbox/runtimes/python3.dockerfile +8 -1
- package/dist/lib/sandbox/shell.js +2 -1
- package/dist/lib/task/commands/issue-body.js +10 -5
- package/dist/lib/task/events.js +1 -1
- package/dist/lib/task/issue-form.js +2 -0
- package/dist/lib/task/review-fingerprint.js +9 -0
- package/dist/lib/task/verification-engine.js +34 -17
- package/dist/lib/task/verification-review.js +1 -1
- package/dist/lib/task/verification.js +1 -1
- package/lib/internal/platform-release-notes.ts +55 -0
- package/lib/internal/release-workflow.ts +105 -3
- package/lib/platform/github-release-notes.ts +132 -0
- package/lib/platform/issues.ts +6 -4
- package/lib/platform/merged-pr-equivalence.ts +70 -0
- package/lib/platform/pr-checks.ts +8 -7
- package/lib/platform/pull-requests.ts +9 -3
- package/lib/platform/release-notes.ts +167 -0
- package/lib/platform/verification-required.ts +31 -5
- package/lib/sandbox/build-proxy.ts +94 -0
- package/lib/sandbox/commands/create.ts +77 -11
- package/lib/sandbox/commands/rebuild.ts +29 -7
- package/lib/sandbox/image-build.ts +4 -1
- package/lib/sandbox/runtimes/ai-tools.dockerfile +24 -3
- package/lib/sandbox/runtimes/base.dockerfile +8 -1
- package/lib/sandbox/runtimes/java17.dockerfile +8 -1
- package/lib/sandbox/runtimes/java21.dockerfile +8 -1
- package/lib/sandbox/runtimes/node20.dockerfile +8 -1
- package/lib/sandbox/runtimes/node22.dockerfile +8 -1
- package/lib/sandbox/runtimes/python3.dockerfile +8 -1
- package/lib/sandbox/shell.ts +3 -1
- package/lib/task/commands/issue-body.ts +10 -5
- package/lib/task/events.ts +1 -1
- package/lib/task/issue-form.ts +4 -0
- package/lib/task/review-fingerprint.ts +10 -1
- package/lib/task/verification-engine.ts +33 -17
- package/lib/task/verification-review.ts +1 -0
- package/lib/task/verification.ts +1 -1
- package/package.json +1 -1
- package/templates/.agents/rules/release-commands.en.md +2 -2
- package/templates/.agents/rules/release-commands.github.en.md +10 -40
- package/templates/.agents/rules/release-commands.github.zh-CN.md +9 -39
- package/templates/.agents/rules/release-commands.zh-CN.md +2 -2
- package/templates/.agents/rules/review-handshake.en.md +2 -1
- package/templates/.agents/rules/review-handshake.zh-CN.md +2 -1
- package/templates/.agents/rules/testing-discipline.en.md +20 -2
- package/templates/.agents/rules/testing-discipline.zh-CN.md +20 -2
- package/templates/.agents/skills/analyze-task/SKILL.en.md +4 -1
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +4 -1
- package/templates/.agents/skills/complete-task/SKILL.en.md +3 -3
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +3 -3
- package/templates/.agents/skills/complete-task/config/verify.en.json +0 -1
- package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +0 -1
- package/templates/.agents/skills/create-release-note/SKILL.en.md +17 -38
- package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +16 -37
- package/templates/.agents/skills/create-task/SKILL.en.md +4 -0
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +4 -0
- package/templates/.agents/skills/create-task/reference/context-capture.en.md +29 -0
- package/templates/.agents/skills/create-task/reference/context-capture.zh-CN.md +29 -0
- package/templates/.agents/templates/task.en.md +18 -0
- package/templates/.agents/templates/task.zh-CN.md +18 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
import { resolvePlatformContext } from './context.ts';
|
|
6
|
+
import {
|
|
7
|
+
fetchGitHubReleaseNoteData,
|
|
8
|
+
publishGitHubReleaseNotes
|
|
9
|
+
} from './github-release-notes.ts';
|
|
10
|
+
import type { ReleaseNoteActor } from './github-release-notes.ts';
|
|
11
|
+
import type { GitHubClient } from './github-client.ts';
|
|
12
|
+
|
|
13
|
+
type ReleaseNoteOptions = {
|
|
14
|
+
cwd?: string;
|
|
15
|
+
platformType?: string;
|
|
16
|
+
client?: GitHubClient;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type ReleaseNoteCommit = { oid: string; title: string; authors: ReleaseNoteActor[] };
|
|
20
|
+
|
|
21
|
+
const unsupportedError = {
|
|
22
|
+
code: 'PLATFORM_RELEASE_NOTES_UNSUPPORTED',
|
|
23
|
+
message: 'Release notes are not supported for the configured platform',
|
|
24
|
+
retryable: false
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function baseResult(status: 'no-op' | 'failed' | 'blocked') {
|
|
28
|
+
return {
|
|
29
|
+
status,
|
|
30
|
+
changed: false,
|
|
31
|
+
range: null,
|
|
32
|
+
history: [],
|
|
33
|
+
pullRequests: [],
|
|
34
|
+
commits: [] as ReleaseNoteCommit[],
|
|
35
|
+
error: status === 'no-op' ? unsupportedError : null
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function git(cwd: string, args: string[]): string {
|
|
40
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function configuredPlatform(cwd: string): string | null {
|
|
44
|
+
try {
|
|
45
|
+
const value = JSON.parse(fs.readFileSync(path.join(cwd, '.agents', '.airc.json'), 'utf8'));
|
|
46
|
+
return typeof value?.platform?.type === 'string' ? value.platform.type : null;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function releaseNoteContext(
|
|
53
|
+
input: { fromTag: string; toTag: string; branch: string; historyLimit?: number },
|
|
54
|
+
options: ReleaseNoteOptions = {}
|
|
55
|
+
) {
|
|
56
|
+
const historyLimit = input.historyLimit ?? 3;
|
|
57
|
+
if (!input.fromTag || !input.toTag || !input.branch || !Number.isInteger(historyLimit) || historyLimit < 1 || historyLimit > 20) {
|
|
58
|
+
return { ...baseResult('failed'), error: { code: 'RELEASE_NOTES_INPUT_INVALID', message: 'from-tag, to-tag, branch, and a history limit from 1 to 20 are required', retryable: false } };
|
|
59
|
+
}
|
|
60
|
+
const cwd = path.resolve(options.cwd || process.cwd());
|
|
61
|
+
const platformType = options.platformType ?? configuredPlatform(cwd);
|
|
62
|
+
if (platformType !== 'github') return baseResult('no-op');
|
|
63
|
+
let fromOid: string;
|
|
64
|
+
let toOid: string;
|
|
65
|
+
let fromTime: string;
|
|
66
|
+
let toTime: string;
|
|
67
|
+
let commitFacts: Array<{ oid: string; title: string }>;
|
|
68
|
+
try {
|
|
69
|
+
fromOid = git(cwd, ['rev-parse', '--verify', `${input.fromTag}^{commit}`]);
|
|
70
|
+
toOid = git(cwd, ['rev-parse', '--verify', `${input.toTag}^{commit}`]);
|
|
71
|
+
git(cwd, ['merge-base', '--is-ancestor', fromOid, toOid]);
|
|
72
|
+
fromTime = git(cwd, ['show', '-s', '--format=%cI', fromOid]);
|
|
73
|
+
toTime = git(cwd, ['show', '-s', '--format=%cI', toOid]);
|
|
74
|
+
const raw = git(cwd, ['log', '--reverse', '--no-merges', '--format=%H%x00%s', `${fromOid}..${toOid}`]);
|
|
75
|
+
commitFacts = raw ? raw.split('\n').map((line) => {
|
|
76
|
+
const [oid = '', title = ''] = line.split('\0');
|
|
77
|
+
return { oid, title };
|
|
78
|
+
}) : [];
|
|
79
|
+
} catch {
|
|
80
|
+
return { ...baseResult('failed'), error: { code: 'RELEASE_NOTES_RANGE_INVALID', message: 'The release tag range is missing or is not ancestral', retryable: false } };
|
|
81
|
+
}
|
|
82
|
+
const context = resolvePlatformContext({ cwd, platformType, client: options.client });
|
|
83
|
+
if (!context.platform.repository) {
|
|
84
|
+
return {
|
|
85
|
+
...baseResult(context.status === 'blocked' ? 'blocked' : 'failed'),
|
|
86
|
+
error: context.error || { code: 'PLATFORM_CONTEXT_UNAVAILABLE', message: 'Platform repository is unavailable', retryable: context.status === 'blocked' }
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const platform = fetchGitHubReleaseNoteData(
|
|
90
|
+
{
|
|
91
|
+
repository: context.platform.repository,
|
|
92
|
+
commitOids: commitFacts.map((item) => item.oid),
|
|
93
|
+
branch: input.branch,
|
|
94
|
+
historyLimit,
|
|
95
|
+
fromTime,
|
|
96
|
+
toTime
|
|
97
|
+
},
|
|
98
|
+
{ cwd, client: options.client }
|
|
99
|
+
);
|
|
100
|
+
if (platform.status === 'blocked' || platform.status === 'failed' || !('authors' in platform)) {
|
|
101
|
+
return { ...baseResult(platform.status), error: platform.error };
|
|
102
|
+
}
|
|
103
|
+
const commits = commitFacts.map((commit) => {
|
|
104
|
+
const seen = new Set<string>();
|
|
105
|
+
const authors = (platform.authors.get(commit.oid) || []).filter((actor) => {
|
|
106
|
+
const key = actor.login || actor.email?.toLowerCase() || actor.name.toLowerCase();
|
|
107
|
+
if (seen.has(key)) return false;
|
|
108
|
+
seen.add(key);
|
|
109
|
+
return true;
|
|
110
|
+
});
|
|
111
|
+
return { ...commit, authors };
|
|
112
|
+
});
|
|
113
|
+
const pullRequests = platform.pullRequests
|
|
114
|
+
.map((item) => ({
|
|
115
|
+
number: Number(item.number),
|
|
116
|
+
title: String(item.title || ''),
|
|
117
|
+
body: String(item.body || ''),
|
|
118
|
+
url: String(item.url || ''),
|
|
119
|
+
mergedAt: String(item.mergedAt || ''),
|
|
120
|
+
labels: Array.isArray(item.labels) ? item.labels.map((label) => String((label as { name?: string }).name || label)) : [],
|
|
121
|
+
author: item.author && typeof item.author === 'object'
|
|
122
|
+
? { login: String((item.author as { login?: string }).login || '').toLowerCase(), bot: String((item.author as { login?: string }).login || '').endsWith('[bot]') }
|
|
123
|
+
: null,
|
|
124
|
+
closingIssues: Array.isArray(item.closingIssuesReferences)
|
|
125
|
+
? item.closingIssuesReferences.map((issue) => {
|
|
126
|
+
const value = issue as Record<string, unknown>;
|
|
127
|
+
return {
|
|
128
|
+
number: Number(value.number),
|
|
129
|
+
title: String(value.title || ''),
|
|
130
|
+
url: String(value.url || ''),
|
|
131
|
+
labels: Array.isArray(value.labels) ? value.labels.map((label) => String((label as { name?: string }).name || label)) : [],
|
|
132
|
+
author: value.author && typeof value.author === 'object'
|
|
133
|
+
? { login: String((value.author as { login?: string }).login || '').toLowerCase(), bot: String((value.author as { login?: string }).login || '').endsWith('[bot]') }
|
|
134
|
+
: null
|
|
135
|
+
};
|
|
136
|
+
}) : []
|
|
137
|
+
}))
|
|
138
|
+
.sort((left, right) => left.number - right.number);
|
|
139
|
+
return {
|
|
140
|
+
status: 'no-op' as const,
|
|
141
|
+
changed: false,
|
|
142
|
+
range: { fromTag: input.fromTag, toTag: input.toTag, fromOid, toOid },
|
|
143
|
+
history: platform.history,
|
|
144
|
+
pullRequests,
|
|
145
|
+
commits,
|
|
146
|
+
error: null
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function publishReleaseNotes(
|
|
151
|
+
input: { tag: string; title: string; notesFile: string; dryRun?: boolean },
|
|
152
|
+
options: ReleaseNoteOptions = {}
|
|
153
|
+
) {
|
|
154
|
+
if (!input.tag || !input.title || !input.notesFile) {
|
|
155
|
+
return { status: 'failed' as const, changed: false, operation: null, url: null, error: { code: 'RELEASE_NOTES_INPUT_INVALID', message: 'tag, title, and notes-file are required', retryable: false } };
|
|
156
|
+
}
|
|
157
|
+
const cwd = path.resolve(options.cwd || process.cwd());
|
|
158
|
+
const platformType = options.platformType ?? configuredPlatform(cwd);
|
|
159
|
+
if (platformType !== 'github') return { status: 'no-op' as const, changed: false, operation: null, url: null, error: unsupportedError };
|
|
160
|
+
const context = resolvePlatformContext({ cwd, platformType, client: options.client });
|
|
161
|
+
if (!context.platform.repository) {
|
|
162
|
+
return { status: context.status === 'blocked' ? 'blocked' as const : 'failed' as const, changed: false, operation: null, url: null, error: context.error };
|
|
163
|
+
}
|
|
164
|
+
return publishGitHubReleaseNotes({ ...input, repository: context.platform.repository }, { cwd, client: options.client });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export { publishReleaseNotes, releaseNoteContext };
|
|
@@ -4,6 +4,8 @@ import { execFileSync } from "node:child_process";
|
|
|
4
4
|
|
|
5
5
|
import { inspectRequiredChecks } from "./pr-checks.ts";
|
|
6
6
|
import { resolvePlatformContext } from "./context.ts";
|
|
7
|
+
import { resolveReviewedHeadRelation } from "./merged-pr-equivalence.ts";
|
|
8
|
+
import { loadPostReviewConfig, resolvePostReviewGlobs } from "../task/review-fingerprint.ts";
|
|
7
9
|
|
|
8
10
|
const CHECK_TYPE = "required-checks";
|
|
9
11
|
const SHA_PATTERN = /^[0-9a-f]{7,40}$/i;
|
|
@@ -50,14 +52,31 @@ export function evaluateRequiredChecks(context: any, shared: any): any {
|
|
|
50
52
|
return shared.failResult(CHECK_TYPE, inspection.error?.message || "Required checks are unavailable", "check_failed");
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
const prHead = inspection.pullRequest?.headSha;
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
const prHead = inspection.pullRequest?.head?.sha || inspection.pullRequest?.headSha;
|
|
56
|
+
const relation = context.relation || (
|
|
57
|
+
context.gitRoot && inspection.pullRequest?.head
|
|
58
|
+
? resolveReviewedHeadRelation({
|
|
59
|
+
gitRoot: context.gitRoot,
|
|
60
|
+
localHead,
|
|
61
|
+
lastReviewedCommit: reviewedHead,
|
|
62
|
+
pullRequest: inspection.pullRequest,
|
|
63
|
+
pathspecs: context.pathspecs || [":/"]
|
|
64
|
+
})
|
|
65
|
+
: localHead === reviewedHead && localHead === prHead
|
|
66
|
+
? { status: "strict" }
|
|
67
|
+
: { status: "failed" }
|
|
68
|
+
);
|
|
69
|
+
if (relation.status !== "strict" && relation.status !== "merged-equivalent") {
|
|
70
|
+
const message = relation.message || "Local HEAD, last reviewed commit, and PR head must match";
|
|
71
|
+
return relation.status === "blocked"
|
|
72
|
+
? shared.blockedResult(CHECK_TYPE, message, "dependency_error")
|
|
73
|
+
: shared.failResult(CHECK_TYPE, message, "check_failed");
|
|
56
74
|
}
|
|
57
75
|
|
|
58
76
|
const state = inspection.checks?.state;
|
|
59
77
|
if (state === "passed" || state === "no-required") {
|
|
60
|
-
|
|
78
|
+
const suffix = relation.status === "merged-equivalent" ? " via verified squash merge equivalence" : "";
|
|
79
|
+
return shared.passResult(CHECK_TYPE, `Required checks are ${state} for PR head ${prHead}${suffix}`);
|
|
61
80
|
}
|
|
62
81
|
if (state === "pending") {
|
|
63
82
|
return shared.blockedResult(CHECK_TYPE, inspection.error?.message || "Required checks are pending", "check_pending");
|
|
@@ -79,5 +98,12 @@ export function check({ taskDir }: any, shared: any): any {
|
|
|
79
98
|
const localHead = readHead(shared.repoRoot);
|
|
80
99
|
if (!localHead) return evaluateRequiredChecks({ metadata: task.metadata, localHead, inspection: null, prFlow }, shared);
|
|
81
100
|
const inspection = inspectRequiredChecks(task.metadata.id, { cwd: shared.repoRoot });
|
|
82
|
-
return evaluateRequiredChecks({
|
|
101
|
+
return evaluateRequiredChecks({
|
|
102
|
+
metadata: task.metadata,
|
|
103
|
+
localHead,
|
|
104
|
+
inspection,
|
|
105
|
+
prFlow,
|
|
106
|
+
gitRoot: shared.repoRoot,
|
|
107
|
+
pathspecs: resolvePostReviewGlobs({}, loadPostReviewConfig(shared.repoRoot))
|
|
108
|
+
}, shared);
|
|
83
109
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import semver from 'semver';
|
|
2
|
+
import { engineDisplayName } from './engine.ts';
|
|
3
|
+
import { runSafeEngine } from './shell.ts';
|
|
4
|
+
|
|
5
|
+
export const BUILD_PROXY_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY'] as const;
|
|
6
|
+
export const MIN_BUILD_PROXY_DOCKER_VERSION = '20.10.0';
|
|
7
|
+
export const MIN_BUILD_PROXY_BUILDKIT_VERSION = '0.9.0';
|
|
8
|
+
|
|
9
|
+
export type BuildProxyPlan = {
|
|
10
|
+
args: string[];
|
|
11
|
+
env: NodeJS.ProcessEnv;
|
|
12
|
+
redactionValues: string[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type EngineRunSafeFn = (engine: string, cmd: string, args: string[]) => string;
|
|
16
|
+
|
|
17
|
+
export function prepareBuildProxy(
|
|
18
|
+
enabled: boolean,
|
|
19
|
+
hostEnv: NodeJS.ProcessEnv,
|
|
20
|
+
engine: string
|
|
21
|
+
): BuildProxyPlan {
|
|
22
|
+
const env = { ...hostEnv };
|
|
23
|
+
if (!enabled) {
|
|
24
|
+
return { args: [], env, redactionValues: [] };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const entries = BUILD_PROXY_ENV_KEYS.flatMap((key) => {
|
|
28
|
+
const value = hostEnv[key]?.trim();
|
|
29
|
+
return value ? [[key, value] as const] : [];
|
|
30
|
+
});
|
|
31
|
+
if (entries.length === 0) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`No non-empty build proxy variables are set. Configure one of: ${BUILD_PROXY_ENV_KEYS.join(', ')}.`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const [key, value] of entries) env[key] = value;
|
|
38
|
+
if (engine === 'wsl2') {
|
|
39
|
+
const existing = (env.WSLENV ?? '').split(':').filter(Boolean);
|
|
40
|
+
env.WSLENV = [...new Set([...existing, ...entries.map(([key]) => key)])].join(':');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
args: entries.flatMap(([key]) => ['--secret', `id=${key},env=${key}`]),
|
|
45
|
+
env,
|
|
46
|
+
redactionValues: entries.map(([, value]) => value)
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parsedVersion(raw: string): string | null {
|
|
51
|
+
return semver.coerce(raw.trim())?.version ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function assertBuildProxyCompatibility(
|
|
55
|
+
engine: string,
|
|
56
|
+
runSafeEngineFn: EngineRunSafeFn = runSafeEngine
|
|
57
|
+
): void {
|
|
58
|
+
const dockerRaw = runSafeEngineFn(engine, 'docker', ['version', '--format', '{{.Server.Version}}']);
|
|
59
|
+
const dockerVersion = parsedVersion(dockerRaw);
|
|
60
|
+
if (!dockerVersion || !semver.gte(dockerVersion, MIN_BUILD_PROXY_DOCKER_VERSION)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Build proxy requires Docker Engine ${MIN_BUILD_PROXY_DOCKER_VERSION} or newer; `
|
|
63
|
+
+ `observed ${dockerVersion ?? 'an unparseable version'}.`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const buildxRaw = runSafeEngineFn(engine, 'docker', ['buildx', 'inspect', '--bootstrap']);
|
|
68
|
+
const versions = [...buildxRaw.matchAll(/BuildKit(?:\s+version)?:\s*v?(\d+\.\d+\.\d+(?:[-+][^\s]+)?)/gi)]
|
|
69
|
+
.map((match) => parsedVersion(match[1] ?? ''))
|
|
70
|
+
.filter((version): version is string => version !== null);
|
|
71
|
+
if (versions.length === 0) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Build proxy requires BuildKit ${MIN_BUILD_PROXY_BUILDKIT_VERSION} or newer, `
|
|
74
|
+
+ 'but the active builder version could not be determined.'
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const oldVersion = versions.find((version) => !semver.gte(version, MIN_BUILD_PROXY_BUILDKIT_VERSION));
|
|
78
|
+
if (oldVersion) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Build proxy observed BuildKit ${oldVersion}; version ${MIN_BUILD_PROXY_BUILDKIT_VERSION} or newer is required.`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function redactBuildProxyValues(text: string, values: readonly string[]): string {
|
|
86
|
+
return [...new Set(values.filter(Boolean))]
|
|
87
|
+
.sort((left, right) => right.length - left.length)
|
|
88
|
+
.reduce((result, value) => result.replaceAll(value, '[REDACTED_BUILD_PROXY]'), text);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function buildProxyFailureHint(engine: string): string {
|
|
92
|
+
return `Build-step proxy inheritance is enabled for ${engineDisplayName(engine)}. `
|
|
93
|
+
+ 'Image pulls and Dockerfile FROM resolution use the Docker daemon or builder proxy configuration.';
|
|
94
|
+
}
|
|
@@ -24,6 +24,12 @@ import {
|
|
|
24
24
|
worktreeDirCandidates
|
|
25
25
|
} from '../constants.ts';
|
|
26
26
|
import { prepareDockerfile } from '../dockerfile.ts';
|
|
27
|
+
import {
|
|
28
|
+
assertBuildProxyCompatibility,
|
|
29
|
+
buildProxyFailureHint,
|
|
30
|
+
prepareBuildProxy,
|
|
31
|
+
redactBuildProxyValues
|
|
32
|
+
} from '../build-proxy.ts';
|
|
27
33
|
import { detectEngine, ensureDocker } from '../engine.ts';
|
|
28
34
|
import {
|
|
29
35
|
commandForEngine,
|
|
@@ -95,7 +101,7 @@ alias gy='gemini --yolo; tput ed'
|
|
|
95
101
|
`;
|
|
96
102
|
const CONTAINER_HOME = '/home/devuser';
|
|
97
103
|
const CONTAINER_SHELL_CONFIG_MOUNT = `${CONTAINER_HOME}/.host-shell-config`;
|
|
98
|
-
const USAGE = `Usage: ai sandbox create <branch> [base] [--cpu <n>] [--memory <n>] [--no-refresh] [--inherit-proxy|-P]
|
|
104
|
+
const USAGE = `Usage: ai sandbox create <branch> [base] [--cpu <n>] [--memory <n>] [--no-refresh] [--inherit-proxy|-P] [--inherit-build-proxy|-B]
|
|
99
105
|
|
|
100
106
|
Host aliases:
|
|
101
107
|
${'~'}/.agent-infra/aliases/sandbox.sh is auto-created on first run and exposed
|
|
@@ -105,7 +111,9 @@ Host aliases:
|
|
|
105
111
|
|
|
106
112
|
Proxy:
|
|
107
113
|
--inherit-proxy, -P Copy non-empty standard host proxy variables into the
|
|
108
|
-
container environment through the private env file
|
|
114
|
+
container environment through the private env file.
|
|
115
|
+
--inherit-build-proxy, -B Pass uppercase HTTP_PROXY, HTTPS_PROXY, and NO_PROXY
|
|
116
|
+
to managed image build steps for this invocation.`;
|
|
109
117
|
const HOST_PROXY_ENV_KEYS = [
|
|
110
118
|
'http_proxy',
|
|
111
119
|
'HTTP_PROXY',
|
|
@@ -116,6 +124,15 @@ const HOST_PROXY_ENV_KEYS = [
|
|
|
116
124
|
'no_proxy',
|
|
117
125
|
'NO_PROXY'
|
|
118
126
|
] as const;
|
|
127
|
+
const MANAGED_GITHUB_GIT_CONFIG = [
|
|
128
|
+
'[credential]',
|
|
129
|
+
'\thelper = !gh auth git-credential',
|
|
130
|
+
'[credential "https://github.com"]',
|
|
131
|
+
'\thelper =',
|
|
132
|
+
'\thelper = !gh auth git-credential',
|
|
133
|
+
'[url "https://github.com/"]',
|
|
134
|
+
'\tinsteadOf = git@github.com:'
|
|
135
|
+
];
|
|
119
136
|
|
|
120
137
|
type SandboxCreateConfig = ReturnType<typeof loadConfig>;
|
|
121
138
|
type PreparedDockerfile = ReturnType<typeof prepareDockerfile>;
|
|
@@ -134,9 +151,9 @@ type ExecSyncOptions = ExecFileSyncOptions & {
|
|
|
134
151
|
};
|
|
135
152
|
type ExecSyncFn = (cmd: string, args: string[], options?: ExecSyncOptions) => Buffer | string;
|
|
136
153
|
type EngineExecFn = (engine: string, cmd: string, args: string[], opts?: ExecFileSyncOptions) => Buffer | string;
|
|
137
|
-
type EngineRunFn = (engine: string, cmd: string, args: string[], opts?: { cwd?: string }) => string;
|
|
154
|
+
type EngineRunFn = (engine: string, cmd: string, args: string[], opts?: { cwd?: string; env?: NodeJS.ProcessEnv }) => string;
|
|
138
155
|
type EngineRunSafeFn = EngineRunFn;
|
|
139
|
-
type EngineRunVerboseFn = (engine: string, cmd: string, args: string[], opts?: { cwd?: string }) => void;
|
|
156
|
+
type EngineRunVerboseFn = (engine: string, cmd: string, args: string[], opts?: { cwd?: string; env?: NodeJS.ProcessEnv }) => void;
|
|
140
157
|
type DirectRunFn = (cmd: string, args: string[], opts?: { cwd?: string }) => string;
|
|
141
158
|
type DirectRunSafeFn = DirectRunFn;
|
|
142
159
|
type DirectRunVerboseFn = (cmd: string, args: string[], opts?: { cwd?: string }) => void;
|
|
@@ -255,6 +272,16 @@ function normalizeContainerHomeSeparators(content: string): string {
|
|
|
255
272
|
return content.replace(containerHomePattern, (value) => value.replaceAll('\\', '/'));
|
|
256
273
|
}
|
|
257
274
|
|
|
275
|
+
function isGitHubCliCredentialHelper(line: string): boolean {
|
|
276
|
+
const helperMatch = line.match(/^\s*helper\s*=\s*(.+?)\s*$/i);
|
|
277
|
+
if (!helperMatch) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const helper = (helperMatch[1] ?? '').trim().replace(/^"(.*)"$/, '$1');
|
|
282
|
+
return /^!\s*(?:"[^"]*[\\/]+gh"|(?:\S*[\\/]+)?gh)\s+auth\s+git-credential(?:\s|$)/i.test(helper);
|
|
283
|
+
}
|
|
284
|
+
|
|
258
285
|
export function sanitizeGitConfig(
|
|
259
286
|
gitconfig: string,
|
|
260
287
|
home: string,
|
|
@@ -272,11 +299,13 @@ export function sanitizeGitConfig(
|
|
|
272
299
|
const sanitized = [];
|
|
273
300
|
let inGpgSection = false;
|
|
274
301
|
let currentSection = '';
|
|
302
|
+
let currentSectionName = '';
|
|
275
303
|
|
|
276
304
|
for (const line of lines) {
|
|
277
305
|
const sectionMatch = line.match(/^\s*\[([^\]]+)\]\s*$/);
|
|
278
306
|
if (sectionMatch) {
|
|
279
307
|
const sectionName = (sectionMatch[1] ?? '').trim();
|
|
308
|
+
currentSectionName = sectionName;
|
|
280
309
|
currentSection = ((sectionName.match(/^([^\s"]+)/)?.[1]) ?? '').toLowerCase();
|
|
281
310
|
inGpgSection = /^gpg(?:\s+"[^"]+")?$/i.test(sectionName);
|
|
282
311
|
if (stripGpg && inGpgSection) {
|
|
@@ -304,11 +333,23 @@ export function sanitizeGitConfig(
|
|
|
304
333
|
if (stripGpg && currentSection === 'user' && /^\s*signingKey\s*=.*$/i.test(line)) {
|
|
305
334
|
continue;
|
|
306
335
|
}
|
|
336
|
+
if (currentSection === 'credential' && isGitHubCliCredentialHelper(line)) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (
|
|
340
|
+
/^url\s+"https:\/\/github\.com\/"$/i.test(currentSectionName)
|
|
341
|
+
&& /^\s*insteadOf\s*=\s*git@github\.com:\s*$/i.test(line)
|
|
342
|
+
) {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
307
345
|
|
|
308
346
|
sanitized.push(line);
|
|
309
347
|
}
|
|
310
348
|
|
|
311
|
-
return
|
|
349
|
+
return [
|
|
350
|
+
...appendSafeDirectories(sanitized, repoRoot),
|
|
351
|
+
...MANAGED_GITHUB_GIT_CONFIG
|
|
352
|
+
].join('\n');
|
|
312
353
|
}
|
|
313
354
|
|
|
314
355
|
export function hostHasGpgKeys(home: string, execFn: ExecSyncFn = execFileSync): boolean {
|
|
@@ -1231,7 +1272,9 @@ export function buildImage(
|
|
|
1231
1272
|
runVerboseFn = runVerboseEngine,
|
|
1232
1273
|
env = process.env,
|
|
1233
1274
|
refresh = false,
|
|
1234
|
-
lastRefresh
|
|
1275
|
+
lastRefresh,
|
|
1276
|
+
buildProxyArgs = [],
|
|
1277
|
+
buildEnv
|
|
1235
1278
|
}: {
|
|
1236
1279
|
engine?: string;
|
|
1237
1280
|
runFn?: EngineRunFn;
|
|
@@ -1240,6 +1283,8 @@ export function buildImage(
|
|
|
1240
1283
|
env?: NodeJS.ProcessEnv;
|
|
1241
1284
|
refresh?: boolean;
|
|
1242
1285
|
lastRefresh?: number;
|
|
1286
|
+
buildProxyArgs?: string[];
|
|
1287
|
+
buildEnv?: NodeJS.ProcessEnv;
|
|
1243
1288
|
} = {}
|
|
1244
1289
|
): void {
|
|
1245
1290
|
const selectedEngine = engine ?? detectEngine({ engine: config.engine });
|
|
@@ -1252,9 +1297,10 @@ export function buildImage(
|
|
|
1252
1297
|
runSafeFn,
|
|
1253
1298
|
env,
|
|
1254
1299
|
refresh,
|
|
1255
|
-
lastRefresh
|
|
1300
|
+
lastRefresh,
|
|
1301
|
+
buildProxyArgs
|
|
1256
1302
|
}),
|
|
1257
|
-
{ cwd: config.repoRoot }
|
|
1303
|
+
{ cwd: config.repoRoot, env: buildEnv }
|
|
1258
1304
|
);
|
|
1259
1305
|
}
|
|
1260
1306
|
|
|
@@ -1278,6 +1324,7 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1278
1324
|
memory: { type: 'string' },
|
|
1279
1325
|
'no-refresh': { type: 'boolean' },
|
|
1280
1326
|
'inherit-proxy': { type: 'boolean', short: 'P' },
|
|
1327
|
+
'inherit-build-proxy': { type: 'boolean', short: 'B' },
|
|
1281
1328
|
help: { type: 'boolean', short: 'h' }
|
|
1282
1329
|
}
|
|
1283
1330
|
});
|
|
@@ -1363,6 +1410,15 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1363
1410
|
const needsImageBuild = signatureStale || refreshDue;
|
|
1364
1411
|
|
|
1365
1412
|
if (needsImageBuild) {
|
|
1413
|
+
if (values['inherit-build-proxy'] && effectiveConfig.dockerfile) {
|
|
1414
|
+
throw new Error('Build proxy inheritance is unavailable with a custom sandbox Dockerfile.');
|
|
1415
|
+
}
|
|
1416
|
+
const buildProxy = prepareBuildProxy(
|
|
1417
|
+
values['inherit-build-proxy'] ?? false,
|
|
1418
|
+
process.env,
|
|
1419
|
+
engine
|
|
1420
|
+
);
|
|
1421
|
+
if (values['inherit-build-proxy']) assertBuildProxyCompatibility(engine);
|
|
1366
1422
|
const buildRefresh = !imageExists || refreshDue;
|
|
1367
1423
|
const buildLastRefresh = buildRefresh ? now : currentLastRefresh || 0;
|
|
1368
1424
|
|
|
@@ -1379,7 +1435,13 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1379
1435
|
tools,
|
|
1380
1436
|
preparedDockerfile.path,
|
|
1381
1437
|
expectedImageSignature,
|
|
1382
|
-
{
|
|
1438
|
+
{
|
|
1439
|
+
engine,
|
|
1440
|
+
refresh: buildRefresh,
|
|
1441
|
+
lastRefresh: buildLastRefresh,
|
|
1442
|
+
buildProxyArgs: buildProxy.args,
|
|
1443
|
+
buildEnv: buildProxy.env
|
|
1444
|
+
}
|
|
1383
1445
|
);
|
|
1384
1446
|
p.log.success(
|
|
1385
1447
|
refreshDue
|
|
@@ -1392,10 +1454,14 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1392
1454
|
if (refreshDue && !signatureStale && imageExists) {
|
|
1393
1455
|
p.log.warn(
|
|
1394
1456
|
'Scheduled sandbox image refresh failed; continuing with the existing image. ' +
|
|
1395
|
-
commandErrorMessage(error)
|
|
1457
|
+
redactBuildProxyValues(commandErrorMessage(error), buildProxy.redactionValues)
|
|
1458
|
+
+ (values['inherit-build-proxy'] ? ` ${buildProxyFailureHint(engine)}` : '')
|
|
1396
1459
|
);
|
|
1397
1460
|
} else {
|
|
1398
|
-
|
|
1461
|
+
const hint = values['inherit-build-proxy'] ? `\n${buildProxyFailureHint(engine)}` : '';
|
|
1462
|
+
throw new Error(
|
|
1463
|
+
`${redactBuildProxyValues(commandErrorMessage(error), buildProxy.redactionValues)}${hint}`
|
|
1464
|
+
);
|
|
1399
1465
|
}
|
|
1400
1466
|
}
|
|
1401
1467
|
} else {
|
|
@@ -16,8 +16,14 @@ import {
|
|
|
16
16
|
parseImageLabels,
|
|
17
17
|
parseRefreshTimestamp
|
|
18
18
|
} from '../image-build.ts';
|
|
19
|
+
import {
|
|
20
|
+
assertBuildProxyCompatibility,
|
|
21
|
+
buildProxyFailureHint,
|
|
22
|
+
prepareBuildProxy,
|
|
23
|
+
redactBuildProxyValues
|
|
24
|
+
} from '../build-proxy.ts';
|
|
19
25
|
|
|
20
|
-
const USAGE = `Usage: ai sandbox rebuild [--quiet] [--refresh]`;
|
|
26
|
+
const USAGE = `Usage: ai sandbox rebuild [--quiet] [--refresh] [--inherit-build-proxy|-B]`;
|
|
21
27
|
|
|
22
28
|
type EngineRunFn = (engine: string, cmd: string, args: string[], opts?: { cwd?: string }) => string;
|
|
23
29
|
type EngineRunSafeFn = EngineRunFn;
|
|
@@ -33,7 +39,8 @@ export function buildArgs(
|
|
|
33
39
|
runSafeFn = runSafeEngine,
|
|
34
40
|
env = process.env,
|
|
35
41
|
refresh = false,
|
|
36
|
-
lastRefresh
|
|
42
|
+
lastRefresh,
|
|
43
|
+
buildProxyArgs = []
|
|
37
44
|
}: {
|
|
38
45
|
engine?: string;
|
|
39
46
|
runFn?: EngineRunFn;
|
|
@@ -41,6 +48,7 @@ export function buildArgs(
|
|
|
41
48
|
env?: NodeJS.ProcessEnv;
|
|
42
49
|
refresh?: boolean;
|
|
43
50
|
lastRefresh?: number;
|
|
51
|
+
buildProxyArgs?: string[];
|
|
44
52
|
} = {}
|
|
45
53
|
): string[] {
|
|
46
54
|
return buildSandboxImageArgs(config, tools, dockerfilePath, imageSignature, {
|
|
@@ -49,7 +57,8 @@ export function buildArgs(
|
|
|
49
57
|
runSafeFn,
|
|
50
58
|
env,
|
|
51
59
|
refresh,
|
|
52
|
-
lastRefresh
|
|
60
|
+
lastRefresh,
|
|
61
|
+
buildProxyArgs
|
|
53
62
|
});
|
|
54
63
|
}
|
|
55
64
|
|
|
@@ -77,6 +86,7 @@ export async function rebuild(args: string[]): Promise<void> {
|
|
|
77
86
|
options: {
|
|
78
87
|
refresh: { type: 'boolean' },
|
|
79
88
|
quiet: { type: 'boolean', short: 'q' },
|
|
89
|
+
'inherit-build-proxy': { type: 'boolean', short: 'B' },
|
|
80
90
|
help: { type: 'boolean', short: 'h' }
|
|
81
91
|
}
|
|
82
92
|
});
|
|
@@ -95,6 +105,11 @@ export async function rebuild(args: string[]): Promise<void> {
|
|
|
95
105
|
const engine = detectEngine(config);
|
|
96
106
|
|
|
97
107
|
await ensureDocker(config, undefined);
|
|
108
|
+
if (values['inherit-build-proxy'] && config.dockerfile) {
|
|
109
|
+
throw new Error('Build proxy inheritance is unavailable with a custom sandbox Dockerfile.');
|
|
110
|
+
}
|
|
111
|
+
const buildProxy = prepareBuildProxy(values['inherit-build-proxy'] ?? false, process.env, engine);
|
|
112
|
+
if (values['inherit-build-proxy']) assertBuildProxyCompatibility(engine);
|
|
98
113
|
const lastRefresh = refresh ? Date.now() : readExistingLastRefresh(config, engine);
|
|
99
114
|
p.intro(pc.cyan('Rebuilding sandbox image'));
|
|
100
115
|
|
|
@@ -105,9 +120,11 @@ export async function rebuild(args: string[]): Promise<void> {
|
|
|
105
120
|
runEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, {
|
|
106
121
|
engine,
|
|
107
122
|
refresh,
|
|
108
|
-
lastRefresh
|
|
123
|
+
lastRefresh,
|
|
124
|
+
buildProxyArgs: buildProxy.args
|
|
109
125
|
}), {
|
|
110
|
-
cwd: config.repoRoot
|
|
126
|
+
cwd: config.repoRoot,
|
|
127
|
+
env: buildProxy.env
|
|
111
128
|
});
|
|
112
129
|
spinner.stop(pc.green('Sandbox image rebuilt'));
|
|
113
130
|
} else {
|
|
@@ -118,13 +135,18 @@ export async function rebuild(args: string[]): Promise<void> {
|
|
|
118
135
|
buildArgs(config, tools, preparedDockerfile.path, imageSignature, {
|
|
119
136
|
engine,
|
|
120
137
|
refresh,
|
|
121
|
-
lastRefresh
|
|
138
|
+
lastRefresh,
|
|
139
|
+
buildProxyArgs: buildProxy.args
|
|
122
140
|
}),
|
|
123
|
-
{ cwd: config.repoRoot }
|
|
141
|
+
{ cwd: config.repoRoot, env: buildProxy.env }
|
|
124
142
|
);
|
|
125
143
|
p.log.success(pc.green('Sandbox image rebuilt'));
|
|
126
144
|
}
|
|
127
145
|
pruneSandboxDanglingImages(config, engine);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
const message = redactBuildProxyValues(error instanceof Error ? error.message : String(error), buildProxy.redactionValues);
|
|
148
|
+
const hint = values['inherit-build-proxy'] ? `\n${buildProxyFailureHint(engine)}` : '';
|
|
149
|
+
throw new Error(`${message}${hint}`);
|
|
128
150
|
} finally {
|
|
129
151
|
preparedDockerfile.cleanup();
|
|
130
152
|
}
|
|
@@ -44,7 +44,8 @@ export function buildSandboxImageArgs(
|
|
|
44
44
|
runSafeFn = runSafeEngine,
|
|
45
45
|
env = process.env,
|
|
46
46
|
refresh = false,
|
|
47
|
-
lastRefresh
|
|
47
|
+
lastRefresh,
|
|
48
|
+
buildProxyArgs = []
|
|
48
49
|
}: {
|
|
49
50
|
engine?: string;
|
|
50
51
|
runFn?: EngineRunFn;
|
|
@@ -52,6 +53,7 @@ export function buildSandboxImageArgs(
|
|
|
52
53
|
env?: NodeJS.ProcessEnv;
|
|
53
54
|
refresh?: boolean;
|
|
54
55
|
lastRefresh?: number;
|
|
56
|
+
buildProxyArgs?: string[];
|
|
55
57
|
} = {}
|
|
56
58
|
): string[] {
|
|
57
59
|
const selectedEngine = engine ?? detectEngine({ engine: config.engine });
|
|
@@ -79,6 +81,7 @@ export function buildSandboxImageArgs(
|
|
|
79
81
|
'--label',
|
|
80
82
|
`${sandboxImageConfigLabel(config)}=${imageSignature}`
|
|
81
83
|
];
|
|
84
|
+
args.push(...buildProxyArgs);
|
|
82
85
|
|
|
83
86
|
if (lastRefresh !== undefined) {
|
|
84
87
|
args.push('--label', `${sandboxImageRefreshLabel(config)}=${lastRefresh}`);
|