From e12e5588acfe0894a7de3b0749817025f4e6e7a1 Mon Sep 17 00:00:00 2001 From: Hasan TASKIN Date: Fri, 28 Aug 2026 02:20:08 +0200 Subject: [PATCH] feat: ship the git identity with runner autoconfig --- CHANGELOG.md | 7 +++ package.json | 2 +- packages/cli/package.json | 2 +- packages/cli/src/i18n.ts | 10 ++++ packages/cli/src/index.ts | 6 ++ packages/cli/src/runner-commands.test.ts | 75 ++++++++++++++++++++++++ packages/cli/src/runner-commands.ts | 69 +++++++++++++++++++++- packages/cli/src/runner-daemon.test.ts | 35 +++++++++++ packages/cli/src/runner-daemon.ts | 26 +++++++- packages/cli/src/runner-secrets.test.ts | 70 +++++++++++++++++++++- packages/cli/src/runner-secrets.ts | 55 +++++++++++++++-- packages/cli/src/task-runner.ts | 19 +++++- 12 files changed, 365 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f10b153..1f3f856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to `codesema` (the npm package in `packages/cli`) are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org). +## [0.18.4] - 2026-08-28 + +### Added + +- **`runner autoconfig` now ships your git identity with the sealed secrets.** The workstation offers its own `git config user.name`/`user.email` (confirmed interactively, or passed with `--git-name`/`--git-email`), the blob carries it end-to-end encrypted like the tokens, and the runner pins it as the machine global git config on delivery, through `runner await-secrets` and the daemon rotation alike. A fresh server no longer fails every turn commit with "Please tell me who you are". +- **Commit signature of last resort.** When neither the payload nor the host carries any git identity, the runner signs its turn commits as `codesema ` instead of stranding finished work uncommitted in the worktree. + ## [0.18.3] - 2026-08-28 ### Fixed diff --git a/package.json b/package.json index c0a91e4..0fd63a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codesema-tools", - "version": "0.18.3", + "version": "0.18.4", "private": true, "type": "module", "workspaces": [ diff --git a/packages/cli/package.json b/packages/cli/package.json index 3554d39..ab7b098 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "codesema", - "version": "0.18.3", + "version": "0.18.4", "description": "Local merge request review, step by step. Your AI agent reviews, codesema displays.", "license": "MIT", "author": "Hasan TASKIN", diff --git a/packages/cli/src/i18n.ts b/packages/cli/src/i18n.ts index 316ba35..7f1c2ac 100644 --- a/packages/cli/src/i18n.ts +++ b/packages/cli/src/i18n.ts @@ -475,6 +475,11 @@ terminal, offers to upgrade when a newer version exists. Set CODESEMA_NO_UPDATE_ 'runner.autoconfigReuseClaudeToken': "Reuse this machine's Claude Code OAuth token?", 'runner.autoconfigPasteClaudeToken': 'Paste the Claude Code OAuth token to send', 'runner.autoconfigUseDetectedRepoUrl': "Use {url} as this runner's repo?", + 'runner.autoconfigUseGitIdentity': + 'Send your git identity {name} <{email}> for the runner commits?', + 'runner.autoconfigGitIdentityFlagsIncomplete': + '--git-name and --git-email must be provided together', + 'runner.awaitSecretsGitIdentityApplied': 'git identity applied: {name}', 'runner.autoconfigRepoUrl': 'Repository URL for this runner', 'runner.autoconfigNoSecrets': 'no secret to send: provide at least a GH token or a Claude Code token', @@ -1102,6 +1107,11 @@ CODESEMA_NO_UPDATE_CHECK=1 pour désactiver. 'runner.autoconfigReuseClaudeToken': 'Réutiliser le jeton OAuth Claude Code de cette machine ?', 'runner.autoconfigPasteClaudeToken': 'Collez le jeton OAuth Claude Code à envoyer', 'runner.autoconfigUseDetectedRepoUrl': 'Utiliser {url} comme dépôt de ce runner ?', + 'runner.autoconfigUseGitIdentity': + 'Envoyer ton identité git {name} <{email}> pour les commits du runner ?', + 'runner.autoconfigGitIdentityFlagsIncomplete': + '--git-name et --git-email doivent être fournis ensemble', + 'runner.awaitSecretsGitIdentityApplied': 'identité git appliquée : {name}', 'runner.autoconfigRepoUrl': 'URL du dépôt pour ce runner', 'runner.autoconfigNoSecrets': 'aucun secret à envoyer : fournissez au moins un jeton gh ou un jeton Claude Code', diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 429ae0f..4508277 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -55,6 +55,8 @@ type ParsedValues = { 'gh-token-from-gh'?: boolean | undefined 'claude-token'?: string | undefined 'repo-url'?: string | undefined + 'git-name'?: string | undefined + 'git-email'?: string | undefined } export const COMMAND_NAMES = [ @@ -230,6 +232,8 @@ async function runCommand( ghTokenFromGh: values['gh-token-from-gh'], claudeToken: values['claude-token'], repoUrl: values['repo-url'], + gitName: values['git-name'], + gitEmail: values['git-email'], timeoutSeconds: parseIntFlag('timeout', values.timeout, 1, 86400), }) break @@ -266,6 +270,8 @@ async function main(): Promise { 'gh-token-from-gh': { type: 'boolean' }, 'claude-token': { type: 'string' }, 'repo-url': { type: 'string' }, + 'git-name': { type: 'string' }, + 'git-email': { type: 'string' }, }, }) diff --git a/packages/cli/src/runner-commands.test.ts b/packages/cli/src/runner-commands.test.ts index 2c95c08..d0b1ba3 100644 --- a/packages/cli/src/runner-commands.test.ts +++ b/packages/cli/src/runner-commands.test.ts @@ -21,6 +21,7 @@ import { generateRunnerKeyPair, runnerKeyFingerprint, seal, + unseal, } from './sealed-box.js' process.env.NO_COLOR = '1' @@ -825,6 +826,56 @@ describe('runnerCommand', () => { expect(calls.length).toBe(2) }) + test('--git-name/--git-email travel inside the sealed payload', async () => { + const { publicKey, privateKey } = generateRunnerKeyPair() + const entry = fakeRunnerEntry({ + public_key: publicKey.toString('base64'), + fingerprint: runnerKeyFingerprint(publicKey), + }) + const calls: Call[] = [] + await runnerCommand({ + action: 'autoconfig', + cwd, + fingerprint: entry.fingerprint, + ghTokenFromGh: true, + gitName: 'Naash', + gitEmail: 'naash@example.com', + execFn: () => 'ghp_from_gh', + fetchImpl: fetchSequence( + [ + { status: 200, body: { runners: [entry] } }, + { status: 200, body: {} }, + ], + calls, + ), + }) + const deposited = JSON.parse(String(calls[1]?.init.body)) as { ciphertext: string } + const plaintext = unseal(privateKey, deposited.ciphertext) + expect(plaintext).not.toBeNull() + expect(JSON.parse(plaintext?.toString('utf8') ?? '')).toEqual({ + v: 1, + secrets: { GH_TOKEN: 'ghp_from_gh' }, + git_identity: { name: 'Naash', email: 'naash@example.com' }, + }) + }) + + test('--git-name without --git-email fails before anything is sent', async () => { + const entry = fakeRunnerEntry() + const calls: Call[] = [] + await expect( + runnerCommand({ + action: 'autoconfig', + cwd, + fingerprint: entry.fingerprint, + ghTokenFromGh: true, + gitName: 'Naash', + execFn: () => 'ghp_from_gh', + fetchImpl: fetchSequence([{ status: 200, body: { runners: [entry] } }], calls), + }), + ).rejects.toThrow(t('runner.autoconfigGitIdentityFlagsIncomplete')) + expect(calls.length).toBe(1) + }) + test('--claude-token alone is enough: no gh token is required', async () => { const entry = fakeRunnerEntry() await expect( @@ -986,6 +1037,30 @@ describe('runnerCommand', () => { expect(readFileSync(envPath, 'utf8')).toContain('GH_TOKEN=ghp_first_try') }) + test('a delivered git identity is applied through the seam, never the real global config', async () => { + const ciphertext = sealedPayload({ + v: 1, + secrets: { GH_TOKEN: 'ghp_with_identity' }, + git_identity: { name: 'Naash', email: 'naash@example.com' }, + }) + const applied: unknown[] = [] + const errLines = await captureErr(async () => { + await runnerCommand({ + action: 'await-secrets', + cwd, + envFile: envPath, + applyGitIdentityFn: (deliveredIdentity) => { + applied.push(deliveredIdentity) + }, + fetchImpl: fetchSequence([{ status: 200, body: { secret: { ciphertext } } }], []), + }) + }) + expect(applied).toEqual([{ name: 'Naash', email: 'naash@example.com' }]) + expect(errLines.join('\n')).toContain( + t('runner.awaitSecretsGitIdentityApplied', { name: 'Naash' }), + ) + }) + test('nothing is printed on STDOUT when no repo_url was sent', async () => { const ciphertext = sealedPayload({ v: 1, secrets: { GH_TOKEN: 'ghp_no_repo' } }) const lines = await captureLog(async () => { diff --git a/packages/cli/src/runner-commands.ts b/packages/cli/src/runner-commands.ts index feb5fe6..70c4e23 100644 --- a/packages/cli/src/runner-commands.ts +++ b/packages/cli/src/runner-commands.ts @@ -35,7 +35,12 @@ import { import { t } from './i18n.js' import { loadOrCreateRunnerIdentity, loadRunnerIdentity } from './runner-identity.js' import { readRunnerPidfile, removeRunnerPidfile } from './runner-pidfile.js' -import { applySecretsToEnvFile, sanitizeRunnerSecretsPayload } from './runner-secrets.js' +import { + applyGitIdentity, + applySecretsToEnvFile, + sanitizeRunnerSecretsPayload, + type RunnerGitIdentity, +} from './runner-secrets.js' import { installRunnerService, uninstallRunnerService, @@ -68,6 +73,10 @@ function realExecCommand(command: string, args: readonly string[]): string { return execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) } +const realGitConfig = (args: readonly string[]): void => { + realExecCommand('git', args) +} + /** Same bkctl-style result block as sync.ts's own (private there, so restated here). */ function printResult(statusMessage: string, rows: FieldRow[]): void { console.log('') @@ -97,8 +106,13 @@ export type RunnerCommandOptions = { claudeToken?: string | undefined /** `runner autoconfig` only: repo URL to send, skips the detected-remote confirm/paste. */ repoUrl?: string | undefined + /** `runner autoconfig` only: git author identity to send, skips the detected-identity confirm. Both or neither. */ + gitName?: string | undefined + gitEmail?: string | undefined /** `runner await-secrets` only: seconds to poll before giving up (default 1800). */ timeoutSeconds?: number | undefined + /** Test seam for the delivered git identity: never touches the real global git config in tests. */ + applyGitIdentityFn?: typeof applyGitIdentity | undefined /** Test seam. */ fetchImpl?: typeof fetch | undefined /** Test seam. */ @@ -706,6 +720,44 @@ async function resolveClaudeToken( return pasted ?? undefined } +function tryGitConfig(execFn: ExecCommandFn, key: string): string | null { + try { + return execFn('git', ['config', key]).trim() || null + } catch { + return null + } +} + +/** + * The workstation's own git identity, offered for the runner's commits: the + * runner signs every turn itself, and a fresh server has no identity at all. + * Non-interactive without the flags simply omits it (the runner falls back to + * the codesema signature at commit time). + */ +async function resolveGitIdentity( + opts: RunnerCommandOptions, + seams: AutoconfigPromptSeams & { execFn: ExecCommandFn }, +): Promise { + if (opts.gitName || opts.gitEmail) { + if (!opts.gitName || !opts.gitEmail) { + throw new Error(t('runner.autoconfigGitIdentityFlagsIncomplete')) + } + return { name: opts.gitName, email: opts.gitEmail } + } + if (!isInteractive()) { + return undefined + } + const name = tryGitConfig(seams.execFn, 'user.name') + const email = tryGitConfig(seams.execFn, 'user.email') + if (!name || !email) { + return undefined + } + const confirmed = await seams.confirmFn({ + title: t('runner.autoconfigUseGitIdentity', { name, email }), + }) + return confirmed ? { name, email } : undefined +} + async function resolveRepoUrl( opts: RunnerCommandOptions, seams: AutoconfigPromptSeams, @@ -765,6 +817,7 @@ async function runnerAutoconfig(opts: RunnerCommandOptions): Promise { const ghToken = await resolveGhToken(opts, { ...seams, execFn }) const claudeToken = await resolveClaudeToken(opts, { ...seams, runInheritedFn }) const repoUrl = await resolveRepoUrl(opts, seams) + const gitIdentity = await resolveGitIdentity(opts, { ...seams, execFn }) const secrets = { ...(ghToken ? { GH_TOKEN: ghToken } : {}), @@ -774,7 +827,12 @@ async function runnerAutoconfig(opts: RunnerCommandOptions): Promise { throw new Error(t('runner.autoconfigNoSecrets')) } - const payload = { v: 1 as const, secrets, ...(repoUrl ? { repo_url: repoUrl } : {}) } + const payload = { + v: 1 as const, + secrets, + ...(repoUrl ? { repo_url: repoUrl } : {}), + ...(gitIdentity ? { git_identity: gitIdentity } : {}), + } const ciphertext = seal( Buffer.from(entry.public_key, 'base64'), Buffer.from(JSON.stringify(payload)), @@ -848,6 +906,13 @@ async function runnerAwaitSecrets(opts: RunnerCommandOptions): Promise { console.error(` ${t('runner.awaitSecretsInvalidPayload')}`) } else { applySecretsToEnvFile(envPath, payload.secrets) + if (payload.git_identity) { + const applyGitIdentityFn = opts.applyGitIdentityFn ?? applyGitIdentity + applyGitIdentityFn(payload.git_identity, realGitConfig) + console.error( + ` ${t('runner.awaitSecretsGitIdentityApplied', { name: payload.git_identity.name })}`, + ) + } if (payload.repo_url) { console.log(payload.repo_url) } diff --git a/packages/cli/src/runner-daemon.test.ts b/packages/cli/src/runner-daemon.test.ts index 0acc337..7e22d75 100644 --- a/packages/cli/src/runner-daemon.test.ts +++ b/packages/cli/src/runner-daemon.test.ts @@ -859,6 +859,41 @@ describe('startRunnerDaemon', () => { } }) + test('a delivered git identity is applied through the seam and logged by name', async () => { + saveGlobalConfig({ + ...loadGlobalConfig(), + syncUrl: 'https://hub.example', + syncWorkspaceId: 'ws1', + syncSecret: 'sec1', + }) + initRepo(cwd, 'https://github.com/o/r.git') + const manager = fakeManager({ cwd }) + const lines: string[] = [] + const applied: unknown[] = [] + const payload: RunnerSecretsPayload = { + v: 1, + secrets: {}, + git_identity: { name: 'Naash', email: 'naash@example.com' }, + } + const handle = startRunnerDaemon({ + manager, + cwd, + fetchImpl: fetchStub(200, { requests: [], tickets: [] }, []), + logFn: (line) => lines.push(line), + loadIdentityFn: () => fakeRunnerIdentity, + claimSecretFn: async () => ({ ok: true, data: { ciphertext: 'sealed-blob' } }), + unsealFn: () => Buffer.from(JSON.stringify(payload), 'utf8'), + sanitizeSecretsFn: (raw) => raw as RunnerSecretsPayload, + applySecretsFn: () => {}, + applyGitIdentityFn: (identity) => { + applied.push(identity) + }, + }) + await handle.stop() + expect(applied).toEqual([{ name: 'Naash', email: 'naash@example.com' }]) + expect(lines.some((l) => l.includes('applied git identity: Naash'))).toBe(true) + }) + test('an undecryptable blob logs a warning and mutates nothing', async () => { saveGlobalConfig({ ...loadGlobalConfig(), diff --git a/packages/cli/src/runner-daemon.ts b/packages/cli/src/runner-daemon.ts index 313f0a4..dbb19a6 100644 --- a/packages/cli/src/runner-daemon.ts +++ b/packages/cli/src/runner-daemon.ts @@ -10,6 +10,7 @@ // (D19) rides back on that same heartbeat and is applied here: ship, reply, // or abandon. +import { execFileSync } from 'node:child_process' import { runnerEnvPath } from './config.js' import { isActiveTaskStatus, type ArmOrder, type ArmTicketRequest } from './contract.js' import { @@ -21,7 +22,12 @@ import { listTickets, } from './hub-client.js' import { loadRunnerIdentity } from './runner-identity.js' -import { applySecretsToEnvFile, sanitizeRunnerSecretsPayload } from './runner-secrets.js' +import { + applyGitIdentity, + applySecretsToEnvFile, + sanitizeRunnerSecretsPayload, + type RunnerGitIdentity, +} from './runner-secrets.js' import { unseal } from './sealed-box.js' import { loadSyncCredentials, type SyncCredentials } from './sync.js' import { createHubTicketTask } from './task-hub-ticket.js' @@ -57,6 +63,7 @@ type DaemonContext = { unsealFn: typeof unseal sanitizeSecretsFn: typeof sanitizeRunnerSecretsPayload applySecretsFn: typeof applySecretsToEnvFile + applyGitIdentityFn: (identity: RunnerGitIdentity) => void } /** Whether this half-tick saw a network failure or a 5xx: the ONLY conditions that back off the next tick. */ @@ -244,6 +251,14 @@ async function checkPendingSecretRotation( if (appliedKeys.length > 0) { ctx.log(`applied rotated runner secret(s): ${appliedKeys.join(', ')}`) } + if (payload.git_identity) { + try { + ctx.applyGitIdentityFn(payload.git_identity) + ctx.log(`applied git identity: ${payload.git_identity.name}`) + } catch (err) { + ctx.log(`could not apply the delivered git identity: ${errorMessage(err)}`) + } + } } async function tick(ctx: DaemonContext): Promise { @@ -375,6 +390,7 @@ export type StartRunnerDaemonOptions = { unsealFn?: typeof unseal /** Test seam. */ sanitizeSecretsFn?: typeof sanitizeRunnerSecretsPayload + applyGitIdentityFn?: (identity: RunnerGitIdentity) => void /** Test seam. */ applySecretsFn?: typeof applySecretsToEnvFile } @@ -390,6 +406,13 @@ export function startRunnerDaemon(opts: StartRunnerDaemonOptions): RunnerDaemonH const unsealFn = opts.unsealFn ?? unseal const sanitizeSecretsFn = opts.sanitizeSecretsFn ?? sanitizeRunnerSecretsPayload const applySecretsFn = opts.applySecretsFn ?? applySecretsToEnvFile + const applyGitIdentityFn = + opts.applyGitIdentityFn ?? + ((identity: RunnerGitIdentity): void => { + applyGitIdentity(identity, (args) => + execFileSync('git', [...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }), + ) + }) const controller = new AbortController() const loggedOnce = new Set() @@ -411,6 +434,7 @@ export function startRunnerDaemon(opts: StartRunnerDaemonOptions): RunnerDaemonH unsealFn, sanitizeSecretsFn, applySecretsFn, + applyGitIdentityFn, } let backoffMs = intervalMs diff --git a/packages/cli/src/runner-secrets.test.ts b/packages/cli/src/runner-secrets.test.ts index 615afba..b3df336 100644 --- a/packages/cli/src/runner-secrets.test.ts +++ b/packages/cli/src/runner-secrets.test.ts @@ -2,7 +2,11 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, test } from 'bun:test' -import { applySecretsToEnvFile, sanitizeRunnerSecretsPayload } from './runner-secrets.js' +import { + applyGitIdentity, + applySecretsToEnvFile, + sanitizeRunnerSecretsPayload, +} from './runner-secrets.js' describe('sanitizeRunnerSecretsPayload', () => { test('accepts a full valid payload and trims values', () => { @@ -178,3 +182,67 @@ describe('applySecretsToEnvFile', () => { expect(existsSync(`${envPath}.tmp`)).toBe(false) }) }) + +describe('sanitizeRunnerSecretsPayload git_identity', () => { + test('accepts and trims a git identity next to the secrets', () => { + const result = sanitizeRunnerSecretsPayload({ + v: 1, + secrets: { GH_TOKEN: 'token' }, + git_identity: { name: ' Naash ', email: ' naash@example.com ' }, + }) + expect(result?.git_identity).toEqual({ name: 'Naash', email: 'naash@example.com' }) + }) + + test('a git identity alone is a valid payload (secrets stay untouched elsewhere)', () => { + const result = sanitizeRunnerSecretsPayload({ + v: 1, + secrets: {}, + git_identity: { name: 'Naash', email: 'naash@example.com' }, + }) + expect(result).toEqual({ + v: 1, + secrets: {}, + git_identity: { name: 'Naash', email: 'naash@example.com' }, + }) + }) + + test('a repo_url alone is a valid payload too', () => { + const result = sanitizeRunnerSecretsPayload({ + v: 1, + secrets: {}, + repo_url: 'https://example.com/o/r.git', + }) + expect(result?.repo_url).toBe('https://example.com/o/r.git') + }) + + test('a name carrying a control character rejects the whole payload (git config injection guard)', () => { + expect( + sanitizeRunnerSecretsPayload({ + v: 1, + secrets: { GH_TOKEN: 'token' }, + git_identity: { name: 'a\nb', email: 'naash@example.com' }, + }), + ).toBeNull() + }) + + test('a half identity (name without email) rejects the payload', () => { + expect( + sanitizeRunnerSecretsPayload({ + v: 1, + secrets: { GH_TOKEN: 'token' }, + git_identity: { name: 'Naash' }, + }), + ).toBeNull() + }) +}) + +describe('applyGitIdentity', () => { + test('pins name and email as global git config, in that order', () => { + const calls: string[][] = [] + applyGitIdentity({ name: 'Naash', email: 'naash@example.com' }, (args) => calls.push([...args])) + expect(calls).toEqual([ + ['config', '--global', 'user.name', 'Naash'], + ['config', '--global', 'user.email', 'naash@example.com'], + ]) + }) +}) diff --git a/packages/cli/src/runner-secrets.ts b/packages/cli/src/runner-secrets.ts index a2e52ec..f3e5018 100644 --- a/packages/cli/src/runner-secrets.ts +++ b/packages/cli/src/runner-secrets.ts @@ -1,6 +1,11 @@ import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs' import { nodeAtomicWriteIo, writeFileAtomic, type AtomicWriteIo } from './atomic-write.js' +export type RunnerGitIdentity = { + name: string + email: string +} + export type RunnerSecretsPayload = { v: 1 secrets: { @@ -8,11 +13,20 @@ export type RunnerSecretsPayload = { GH_TOKEN?: string } repo_url?: string + git_identity?: RunnerGitIdentity +} + +/** Commit signature of last resort when neither the payload nor the host carries one. */ +export const RUNNER_FALLBACK_GIT_IDENTITY: RunnerGitIdentity = { + name: 'codesema', + email: 'noreply@codesema.com', } const SECRET_KEYS = ['CLAUDE_CODE_OAUTH_TOKEN', 'GH_TOKEN'] as const const SECRET_VALUE_MAX = 4096 const REPO_URL_MAX = 2048 +const GIT_IDENTITY_NAME_MAX = 128 +const GIT_IDENTITY_EMAIL_MAX = 254 // \p{Cc} covers every Unicode control character (newline, carriage return, // tab, ...). A secret or URL carrying one is either corrupted or an attempt // to inject extra lines into the KEY=value env file applySecretsToEnvFile @@ -51,9 +65,6 @@ export function sanitizeRunnerSecretsPayload(raw: unknown): RunnerSecretsPayload } secrets[key] = value } - if (Object.keys(secrets).length === 0) { - return null - } let repoUrl: string | undefined if (r.repo_url !== undefined) { @@ -64,7 +75,43 @@ export function sanitizeRunnerSecretsPayload(raw: unknown): RunnerSecretsPayload repoUrl = value } - return { v: 1, secrets, ...(repoUrl !== undefined ? { repo_url: repoUrl } : {}) } + let gitIdentity: RunnerGitIdentity | undefined + if (r.git_identity !== undefined) { + if (!r.git_identity || typeof r.git_identity !== 'object') { + return null + } + const rawIdentity = r.git_identity as Record + const name = sanitizeBoundedToken(rawIdentity.name, GIT_IDENTITY_NAME_MAX) + const email = sanitizeBoundedToken(rawIdentity.email, GIT_IDENTITY_EMAIL_MAX) + if (name === null || email === null) { + return null + } + gitIdentity = { name, email } + } + + if (Object.keys(secrets).length === 0 && repoUrl === undefined && gitIdentity === undefined) { + return null + } + + return { + v: 1, + secrets, + ...(repoUrl !== undefined ? { repo_url: repoUrl } : {}), + ...(gitIdentity !== undefined ? { git_identity: gitIdentity } : {}), + } +} + +export type GitConfigExecFn = (args: readonly string[]) => void + +/** + * Pins the delivered identity as the machine's global git config: the runner + * commits every turn itself (task-runner.ts::commitTurn), and a server + * installed from a bare cloud image has no identity at all, which fails every + * commit with "Please tell me who you are". + */ +export function applyGitIdentity(identity: RunnerGitIdentity, runGit: GitConfigExecFn): void { + runGit(['config', '--global', 'user.name', identity.name]) + runGit(['config', '--global', 'user.email', identity.email]) } function parseEnvFile(contents: string): Map { diff --git a/packages/cli/src/task-runner.ts b/packages/cli/src/task-runner.ts index ff26e01..f795a0d 100644 --- a/packages/cli/src/task-runner.ts +++ b/packages/cli/src/task-runner.ts @@ -62,6 +62,7 @@ import { } from './load-cap.js' import { projectIdFor } from './projects.js' import type { ChecksConfig } from './repo-config.js' +import { RUNNER_FALLBACK_GIT_IDENTITY } from './runner-secrets.js' import { bootstrapWorktreeInstall, type BootstrapInstallResult, @@ -1614,10 +1615,26 @@ export function createTaskRunner(opts: TaskRunnerOptions): TaskRunner { return } const filesChanged = dirty.trim().split('\n').length + // A machine with no git identity at all (fresh server) fails every commit + // with "Please tell me who you are": sign as codesema rather than strand + // finished work uncommitted in the worktree. + const identityArgs = tryGit(['config', 'user.email'], record.worktree)?.trim() + ? [] + : [ + '-c', + `user.name=${RUNNER_FALLBACK_GIT_IDENTITY.name}`, + '-c', + `user.email=${RUNNER_FALLBACK_GIT_IDENTITY.email}`, + ] try { git(['add', '-A'], record.worktree) git( - ['commit', '-m', `task(${record.id}): ${record.title} — turn ${record.turns.length}`], + [ + ...identityArgs, + 'commit', + '-m', + `task(${record.id}): ${record.title} — turn ${record.turns.length}`, + ], record.worktree, ) } catch (err) {