Skip to content
This repository was archived by the owner on Mar 15, 2026. It is now read-only.

Commit 7516f67

Browse files
fix: derive commit identity from GitHub token owner
Replace repository-history-based git author/committer resolution with token-owner identity lookup via GitHub /user so commit attribution matches the configured product token owner. When the profile email is unavailable, derive a deterministic noreply address (<id>+<login>@users.noreply.github.com) while preserving stable name fallback to login. Also keep env propagation unchanged for local and containerized runs (GITHUB_TOKEN, GH_TOKEN, GIT_AUTHOR_*, GIT_COMMITTER_*), add a dedicated TokenOwnerProfile DTO to satisfy boundary typing rules, and extend RealCursorCliAgentDriver tests to cover profile identity and noreply fallback behavior.
1 parent c8c3c4f commit 7516f67

File tree

4 files changed

+145
-53
lines changed

4 files changed

+145
-53
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\LlmIntegration\Infrastructure\Service\Dto;
6+
7+
readonly class TokenOwnerProfile
8+
{
9+
public function __construct(
10+
public int $id,
11+
public string $login,
12+
public string $name,
13+
public string $email,
14+
) {
15+
}
16+
}

src/LlmIntegration/Infrastructure/Service/RealCursorCliAgentDriver.php

Lines changed: 61 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace App\LlmIntegration\Infrastructure\Service;
66

77
use App\LlmIntegration\Infrastructure\Service\Dto\CursorCliAgentRunResult;
8+
use App\LlmIntegration\Infrastructure\Service\Dto\TokenOwnerProfile;
89
use App\WorkspaceManagement\Facade\Dto\WorkspaceInfoDto;
910
use JsonException;
1011
use Psr\Log\LoggerInterface;
@@ -56,7 +57,7 @@ private function runLocally(
5657
'agentPath' => $agentPath,
5758
]);
5859

59-
$gitIdentity = $this->resolveGitIdentity($repoPath);
60+
$gitIdentity = $this->resolveGitIdentity($githubToken);
6061
$env = $this->buildEnvironment($cursorApiKey, $githubToken, $gitIdentity);
6162

6263
$process = new Process(
@@ -92,7 +93,7 @@ private function runInContainer(
9293
'agentPath' => $agentPath,
9394
]);
9495

95-
$gitIdentity = $this->resolveGitIdentity($repoPath);
96+
$gitIdentity = $this->resolveGitIdentity($githubToken);
9697
$containerEnv = $this->buildContainerEnvironment($cursorApiKey, $githubToken, $agentHome, $gitIdentity);
9798

9899
$command = ['docker', 'exec'];
@@ -425,7 +426,7 @@ private function buildContainerEnvironment(string $cursorApiKey, string $githubT
425426
/**
426427
* @return array{authorName: string, authorEmail: string, committerName: string, committerEmail: string}
427428
*/
428-
private function resolveGitIdentity(string $repoPath): array
429+
private function resolveGitIdentity(string $githubToken): array
429430
{
430431
$fallback = [
431432
'authorName' => self::FALLBACK_GIT_NAME,
@@ -434,59 +435,80 @@ private function resolveGitIdentity(string $repoPath): array
434435
'committerEmail' => self::FALLBACK_GIT_EMAIL,
435436
];
436437

437-
$process = new Process(
438-
['git', 'log', '-1', '--format=%an%n%ae%n%cn%n%ce'],
439-
$repoPath,
440-
);
441-
$process->setTimeout(10);
442-
$process->run();
443-
444-
if (!$process->isSuccessful()) {
438+
$ownerProfile = $this->fetchGithubTokenOwnerProfile($githubToken);
439+
if ($ownerProfile === null) {
445440
$this->logger->warning('[RealAgent] Falling back to default git identity', [
446-
'reason' => 'git_log_failed',
447-
'stderr' => mb_substr(trim($process->getErrorOutput()), 0, 300),
441+
'reason' => 'github_user_lookup_failed',
448442
]);
449443

450444
return $fallback;
451445
}
452446

453-
$lines = preg_split('/\r\n|\r|\n/', trim($process->getOutput())) ?: [];
454-
if (count($lines) < 4) {
447+
$login = trim($ownerProfile->login);
448+
$name = trim($ownerProfile->name);
449+
$email = trim($ownerProfile->email);
450+
$id = $ownerProfile->id;
451+
452+
if ($login === '') {
455453
$this->logger->warning('[RealAgent] Falling back to default git identity', [
456-
'reason' => 'git_log_unparseable',
457-
'lineCount' => count($lines),
454+
'reason' => 'github_user_login_missing',
458455
]);
459456

460457
return $fallback;
461458
}
462459

463-
$identity = [
464-
'authorName' => trim((string) $lines[0]),
465-
'authorEmail' => trim((string) $lines[1]),
466-
'committerName' => trim((string) $lines[2]),
467-
'committerEmail' => trim((string) $lines[3]),
460+
$resolvedName = $name !== '' ? $name : $login;
461+
$resolvedEmail = $email !== '' ? $email : sprintf('%d+%s@users.noreply.github.com', $id, $login);
462+
$identity = [
463+
'authorName' => $resolvedName,
464+
'authorEmail' => $resolvedEmail,
465+
'committerName' => $resolvedName,
466+
'committerEmail' => $resolvedEmail,
468467
];
469468

470-
if (
471-
$identity['authorName'] === ''
472-
|| $identity['authorEmail'] === ''
473-
|| $identity['committerName'] === ''
474-
|| $identity['committerEmail'] === ''
475-
) {
476-
$this->logger->warning('[RealAgent] Falling back to default git identity', [
477-
'reason' => 'git_log_missing_fields',
478-
]);
469+
$this->logger->info('[RealAgent] Using token-owner git identity', [
470+
'source' => $email !== '' ? 'token_owner_profile' : 'token_owner_noreply_fallback',
471+
'authorName' => $identity['authorName'],
472+
'authorEmail' => $identity['authorEmail'],
473+
'tokenOwnerLogin' => $login,
474+
]);
479475

480-
return $fallback;
481-
}
476+
return $identity;
477+
}
482478

483-
$this->logger->info('[RealAgent] Using repository-derived git identity', [
484-
'authorName' => $identity['authorName'],
485-
'authorEmail' => $identity['authorEmail'],
486-
'committerName' => $identity['committerName'],
487-
'committerEmail' => $identity['committerEmail'],
479+
protected function fetchGithubTokenOwnerProfile(string $githubToken): ?TokenOwnerProfile
480+
{
481+
$context = stream_context_create([
482+
'http' => [
483+
'method' => 'GET',
484+
'header' => implode("\r\n", [
485+
'Authorization: Bearer ' . $githubToken,
486+
'Accept: application/vnd.github+json',
487+
'X-GitHub-Api-Version: 2022-11-28',
488+
'User-Agent: ProductBuilder-RealCursorCliAgentDriver',
489+
]),
490+
'timeout' => 10,
491+
'ignore_errors' => true,
492+
],
488493
]);
489494

490-
return $identity;
495+
$response = file_get_contents('https://api.github.com/user', false, $context);
496+
if (!is_string($response)) {
497+
return null;
498+
}
499+
500+
try {
501+
/** @var array<string, mixed> $data */
502+
$data = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
503+
} catch (JsonException) {
504+
return null;
505+
}
506+
507+
$id = is_int($data['id'] ?? null) ? $data['id'] : 0;
508+
$login = is_string($data['login'] ?? null) ? $data['login'] : '';
509+
$name = is_string($data['name'] ?? null) ? $data['name'] : '';
510+
$email = is_string($data['email'] ?? null) ? $data['email'] : '';
511+
512+
return new TokenOwnerProfile($id, $login, $name, $email);
491513
}
492514
}

src/LlmIntegration/docs/cursor-integration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ The driver passes a minimal, explicit environment to the subprocess:
179179
| `CURSOR_API_KEY` | `product-config.yaml` → ProductConfig entity | Authenticates with the Cursor API |
180180
| `GITHUB_TOKEN` | `product-config.yaml` → ProductConfig entity | Allows the agent to interact with GitHub |
181181
| `GH_TOKEN` | Same as `GITHUB_TOKEN` | Compatibility variable for tools that expect `GH_TOKEN` |
182-
| `GIT_AUTHOR_*` / `GIT_COMMITTER_*` | Derived from repo latest commit, fallback to ProductBuilder bot identity | Ensures commits work even when workspace git config has no identity |
182+
| `GIT_AUTHOR_*` / `GIT_COMMITTER_*` | Derived from GitHub token owner (`GET /user`), with noreply fallback when profile email is missing | Ensures commits are attributed to token owner without mutating git config |
183183
| `HOME` | Inherited from container | Needed so the agent finds `~/.cursor/cli-config.json` and `~/.local/bin/` |
184184
| `PATH` | Inherited from container | Needed so the agent can invoke tools like `git`, `curl`, etc. |
185185

tests/Unit/LlmIntegration/RealCursorCliAgentDriverTest.php

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
declare(strict_types=1);
44

55
use App\LlmIntegration\Infrastructure\Service\CursorCliAgentBinaryManager;
6+
use App\LlmIntegration\Infrastructure\Service\Dto\TokenOwnerProfile;
67
use App\LlmIntegration\Infrastructure\Service\RealCursorCliAgentDriver;
78
use App\WorkspaceManagement\Facade\Dto\WorkspaceInfoDto;
89
use Psr\Log\NullLogger;
@@ -112,6 +113,31 @@ function buildResultLine(bool $isError, string $result, string $sessionId, ?int
112113
], JSON_THROW_ON_ERROR);
113114
}
114115

116+
function buildDriver(CursorCliAgentBinaryManager $binaryManager, ?TokenOwnerProfile $tokenOwnerProfile = null): RealCursorCliAgentDriver
117+
{
118+
$profile = $tokenOwnerProfile ?? new TokenOwnerProfile(
119+
123456,
120+
'token-owner',
121+
'Token Owner',
122+
'token-owner@example.com',
123+
);
124+
125+
return new class($binaryManager, new NullLogger(), $profile) extends RealCursorCliAgentDriver {
126+
public function __construct(
127+
CursorCliAgentBinaryManager $binaryManager,
128+
NullLogger $logger,
129+
private readonly ?TokenOwnerProfile $profile,
130+
) {
131+
parent::__construct($binaryManager, $logger);
132+
}
133+
134+
protected function fetchGithubTokenOwnerProfile(string $githubToken): ?TokenOwnerProfile
135+
{
136+
return $this->profile;
137+
}
138+
};
139+
}
140+
115141
describe('RealCursorCliAgentDriver', function (): void {
116142
describe('successful agent execution with stream-json', function (): void {
117143
it('parses a full NDJSON stream with system, assistant, tool, and result events', function (): void {
@@ -126,7 +152,7 @@ function buildResultLine(bool $isError, string $result, string $sessionId, ?int
126152

127153
runWithFakeAgent($ndjsonLines, function (string $workspacePath): void {
128154
$binaryManager = new CursorCliAgentBinaryManager(new NullLogger());
129-
$driver = new RealCursorCliAgentDriver($binaryManager, new NullLogger());
155+
$driver = buildDriver($binaryManager);
130156

131157
$result = $driver->run('Test prompt', $workspacePath, 'fake-api-key', 'fake-github-token');
132158

@@ -144,7 +170,7 @@ function buildResultLine(bool $isError, string $result, string $sessionId, ?int
144170

145171
runWithFakeAgent($ndjsonLines, function (string $workspacePath): void {
146172
$binaryManager = new CursorCliAgentBinaryManager(new NullLogger());
147-
$driver = new RealCursorCliAgentDriver($binaryManager, new NullLogger());
173+
$driver = buildDriver($binaryManager);
148174

149175
$result = $driver->run('Test prompt', $workspacePath, 'fake-api-key', 'fake-github-token');
150176

@@ -163,7 +189,7 @@ function buildResultLine(bool $isError, string $result, string $sessionId, ?int
163189

164190
runWithFakeAgent($ndjsonLines, function (string $workspacePath): void {
165191
$binaryManager = new CursorCliAgentBinaryManager(new NullLogger());
166-
$driver = new RealCursorCliAgentDriver($binaryManager, new NullLogger());
192+
$driver = buildDriver($binaryManager);
167193

168194
$result = $driver->run('Test prompt', $workspacePath, 'fake-api-key', 'fake-github-token');
169195

@@ -178,7 +204,7 @@ function buildResultLine(bool $isError, string $result, string $sessionId, ?int
178204
it('returns failure result when the process exits with non-zero code', function (): void {
179205
runWithFakeAgent([], function (string $workspacePath): void {
180206
$binaryManager = new CursorCliAgentBinaryManager(new NullLogger());
181-
$driver = new RealCursorCliAgentDriver($binaryManager, new NullLogger());
207+
$driver = buildDriver($binaryManager);
182208

183209
$result = $driver->run('Test prompt', $workspacePath, 'bad-api-key', 'fake-github-token');
184210

@@ -195,7 +221,7 @@ function buildResultLine(bool $isError, string $result, string $sessionId, ?int
195221

196222
runWithFakeAgent($ndjsonLines, function (string $workspacePath): void {
197223
$binaryManager = new CursorCliAgentBinaryManager(new NullLogger());
198-
$driver = new RealCursorCliAgentDriver($binaryManager, new NullLogger());
224+
$driver = buildDriver($binaryManager);
199225

200226
$result = $driver->run('Test prompt', $workspacePath, 'bad-api-key', 'fake-github-token');
201227

@@ -214,7 +240,7 @@ function buildResultLine(bool $isError, string $result, string $sessionId, ?int
214240

215241
runWithFakeAgent($ndjsonLines, function (string $workspacePath): void {
216242
$binaryManager = new CursorCliAgentBinaryManager(new NullLogger());
217-
$driver = new RealCursorCliAgentDriver($binaryManager, new NullLogger());
243+
$driver = buildDriver($binaryManager);
218244

219245
$result = $driver->run('Test prompt', $workspacePath, 'fake-api-key', 'fake-github-token');
220246

@@ -232,7 +258,7 @@ function buildResultLine(bool $isError, string $result, string $sessionId, ?int
232258

233259
runWithFakeAgent($ndjsonLines, function (string $workspacePath): void {
234260
$binaryManager = new CursorCliAgentBinaryManager(new NullLogger());
235-
$driver = new RealCursorCliAgentDriver($binaryManager, new NullLogger());
261+
$driver = buildDriver($binaryManager);
236262

237263
$result = $driver->run('Test prompt', $workspacePath, 'fake-api-key', 'fake-github-token');
238264

@@ -242,24 +268,52 @@ function buildResultLine(bool $isError, string $result, string $sessionId, ?int
242268
});
243269
});
244270

245-
it('injects fallback git identity and both GitHub token env vars', function (): void {
271+
it('injects token-owner git identity and both GitHub token env vars', function (): void {
246272
runWithCustomFakeAgent(
247273
'echo "A=${GIT_AUTHOR_NAME};AE=${GIT_AUTHOR_EMAIL};C=${GIT_COMMITTER_NAME};CE=${GIT_COMMITTER_EMAIL};GH=${GH_TOKEN};GT=${GITHUB_TOKEN}"',
248274
function (string $workspacePath): void {
249275
$binaryManager = new CursorCliAgentBinaryManager(new NullLogger());
250-
$driver = new RealCursorCliAgentDriver($binaryManager, new NullLogger());
276+
$driver = buildDriver($binaryManager, new TokenOwnerProfile(
277+
42,
278+
'octocat',
279+
'Mona Octocat',
280+
'mona@example.com',
281+
));
251282

252283
$result = $driver->run('Test prompt', $workspacePath, 'fake-api-key', 'fake-github-token');
253284

254285
expect($result->success)->toBeTrue();
255-
expect($result->resultText)->toContain('A=ProductBuilder Bot');
256-
expect($result->resultText)->toContain('AE=productbuilder@noreply.github.com');
257-
expect($result->resultText)->toContain('C=ProductBuilder Bot');
258-
expect($result->resultText)->toContain('CE=productbuilder@noreply.github.com');
286+
expect($result->resultText)->toContain('A=Mona Octocat');
287+
expect($result->resultText)->toContain('AE=mona@example.com');
288+
expect($result->resultText)->toContain('C=Mona Octocat');
289+
expect($result->resultText)->toContain('CE=mona@example.com');
259290
expect($result->resultText)->toContain('GH=fake-github-token');
260291
expect($result->resultText)->toContain('GT=fake-github-token');
261292
},
262293
);
263294
});
295+
296+
it('uses noreply email fallback when token owner has no profile email', function (): void {
297+
runWithCustomFakeAgent(
298+
'echo "A=${GIT_AUTHOR_NAME};AE=${GIT_AUTHOR_EMAIL};C=${GIT_COMMITTER_NAME};CE=${GIT_COMMITTER_EMAIL}"',
299+
function (string $workspacePath): void {
300+
$binaryManager = new CursorCliAgentBinaryManager(new NullLogger());
301+
$driver = buildDriver($binaryManager, new TokenOwnerProfile(
302+
42,
303+
'octocat',
304+
'',
305+
'',
306+
));
307+
308+
$result = $driver->run('Test prompt', $workspacePath, 'fake-api-key', 'fake-github-token');
309+
310+
expect($result->success)->toBeTrue();
311+
expect($result->resultText)->toContain('A=octocat');
312+
expect($result->resultText)->toContain('C=octocat');
313+
expect($result->resultText)->toContain('AE=42+octocat@users.noreply.github.com');
314+
expect($result->resultText)->toContain('CE=42+octocat@users.noreply.github.com');
315+
},
316+
);
317+
});
264318
});
265319
});

0 commit comments

Comments
 (0)