@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.
Files changed (82) hide show
  1. package/README.md +6 -1
  2. package/README.zh-CN.md +5 -1
  3. package/dist/bin/internal-cli.js +5 -0
  4. package/dist/lib/internal/platform-release-notes.js +53 -0
  5. package/dist/lib/internal/release-workflow.js +105 -2
  6. package/dist/lib/platform/github-release-notes.js +96 -0
  7. package/dist/lib/platform/issues.js +4 -2
  8. package/dist/lib/platform/merged-pr-equivalence.js +54 -0
  9. package/dist/lib/platform/pr-checks.js +6 -6
  10. package/dist/lib/platform/pull-requests.js +3 -1
  11. package/dist/lib/platform/release-notes.js +143 -0
  12. package/dist/lib/platform/verification-required.js +29 -5
  13. package/dist/lib/sandbox/build-proxy.js +63 -0
  14. package/dist/lib/sandbox/commands/create.js +57 -9
  15. package/dist/lib/sandbox/commands/rebuild.js +24 -7
  16. package/dist/lib/sandbox/image-build.js +2 -1
  17. package/dist/lib/sandbox/runtimes/ai-tools.dockerfile +24 -3
  18. package/dist/lib/sandbox/runtimes/base.dockerfile +8 -1
  19. package/dist/lib/sandbox/runtimes/java17.dockerfile +8 -1
  20. package/dist/lib/sandbox/runtimes/java21.dockerfile +8 -1
  21. package/dist/lib/sandbox/runtimes/node20.dockerfile +8 -1
  22. package/dist/lib/sandbox/runtimes/node22.dockerfile +8 -1
  23. package/dist/lib/sandbox/runtimes/python3.dockerfile +8 -1
  24. package/dist/lib/sandbox/shell.js +2 -1
  25. package/dist/lib/task/commands/issue-body.js +10 -5
  26. package/dist/lib/task/events.js +1 -1
  27. package/dist/lib/task/issue-form.js +2 -0
  28. package/dist/lib/task/review-fingerprint.js +9 -0
  29. package/dist/lib/task/verification-engine.js +34 -17
  30. package/dist/lib/task/verification-review.js +1 -1
  31. package/dist/lib/task/verification.js +1 -1
  32. package/lib/internal/platform-release-notes.ts +55 -0
  33. package/lib/internal/release-workflow.ts +105 -3
  34. package/lib/platform/github-release-notes.ts +132 -0
  35. package/lib/platform/issues.ts +6 -4
  36. package/lib/platform/merged-pr-equivalence.ts +70 -0
  37. package/lib/platform/pr-checks.ts +8 -7
  38. package/lib/platform/pull-requests.ts +9 -3
  39. package/lib/platform/release-notes.ts +167 -0
  40. package/lib/platform/verification-required.ts +31 -5
  41. package/lib/sandbox/build-proxy.ts +94 -0
  42. package/lib/sandbox/commands/create.ts +77 -11
  43. package/lib/sandbox/commands/rebuild.ts +29 -7
  44. package/lib/sandbox/image-build.ts +4 -1
  45. package/lib/sandbox/runtimes/ai-tools.dockerfile +24 -3
  46. package/lib/sandbox/runtimes/base.dockerfile +8 -1
  47. package/lib/sandbox/runtimes/java17.dockerfile +8 -1
  48. package/lib/sandbox/runtimes/java21.dockerfile +8 -1
  49. package/lib/sandbox/runtimes/node20.dockerfile +8 -1
  50. package/lib/sandbox/runtimes/node22.dockerfile +8 -1
  51. package/lib/sandbox/runtimes/python3.dockerfile +8 -1
  52. package/lib/sandbox/shell.ts +3 -1
  53. package/lib/task/commands/issue-body.ts +10 -5
  54. package/lib/task/events.ts +1 -1
  55. package/lib/task/issue-form.ts +4 -0
  56. package/lib/task/review-fingerprint.ts +10 -1
  57. package/lib/task/verification-engine.ts +33 -17
  58. package/lib/task/verification-review.ts +1 -0
  59. package/lib/task/verification.ts +1 -1
  60. package/package.json +1 -1
  61. package/templates/.agents/rules/release-commands.en.md +2 -2
  62. package/templates/.agents/rules/release-commands.github.en.md +10 -40
  63. package/templates/.agents/rules/release-commands.github.zh-CN.md +9 -39
  64. package/templates/.agents/rules/release-commands.zh-CN.md +2 -2
  65. package/templates/.agents/rules/review-handshake.en.md +2 -1
  66. package/templates/.agents/rules/review-handshake.zh-CN.md +2 -1
  67. package/templates/.agents/rules/testing-discipline.en.md +20 -2
  68. package/templates/.agents/rules/testing-discipline.zh-CN.md +20 -2
  69. package/templates/.agents/skills/analyze-task/SKILL.en.md +4 -1
  70. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +4 -1
  71. package/templates/.agents/skills/complete-task/SKILL.en.md +3 -3
  72. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +3 -3
  73. package/templates/.agents/skills/complete-task/config/verify.en.json +0 -1
  74. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +0 -1
  75. package/templates/.agents/skills/create-release-note/SKILL.en.md +17 -38
  76. package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +16 -37
  77. package/templates/.agents/skills/create-task/SKILL.en.md +4 -0
  78. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +4 -0
  79. package/templates/.agents/skills/create-task/reference/context-capture.en.md +29 -0
  80. package/templates/.agents/skills/create-task/reference/context-capture.zh-CN.md +29 -0
  81. package/templates/.agents/templates/task.en.md +18 -0
  82. 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
- if (!prHead || localHead !== reviewedHead || localHead !== prHead) {
55
- return shared.failResult(CHECK_TYPE, "Local HEAD, last reviewed commit, and PR head must match", "check_failed");
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
- return shared.passResult(CHECK_TYPE, `Required checks are ${state} for PR head ${prHead}`);
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({ metadata: task.metadata, localHead, inspection, prFlow }, shared);
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 appendSafeDirectories(sanitized, repoRoot).join('\n');
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
- { engine, refresh: buildRefresh, lastRefresh: buildLastRefresh }
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
- throw error;
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}`);