Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <noreply@codesema.com>` instead of stranding finished work uncommitted in the worktree.

## [0.18.3] - 2026-08-28

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codesema-tools",
"version": "0.18.3",
"version": "0.18.4",
"private": true,
"type": "module",
"workspaces": [
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -266,6 +270,8 @@ async function main(): Promise<void> {
'gh-token-from-gh': { type: 'boolean' },
'claude-token': { type: 'string' },
'repo-url': { type: 'string' },
'git-name': { type: 'string' },
'git-email': { type: 'string' },
},
})

Expand Down
75 changes: 75 additions & 0 deletions packages/cli/src/runner-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
generateRunnerKeyPair,
runnerKeyFingerprint,
seal,
unseal,
} from './sealed-box.js'

process.env.NO_COLOR = '1'
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 () => {
Expand Down
69 changes: 67 additions & 2 deletions packages/cli/src/runner-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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('')
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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<RunnerGitIdentity | undefined> {
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,
Expand Down Expand Up @@ -765,6 +817,7 @@ async function runnerAutoconfig(opts: RunnerCommandOptions): Promise<void> {
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 } : {}),
Expand All @@ -774,7 +827,12 @@ async function runnerAutoconfig(opts: RunnerCommandOptions): Promise<void> {
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)),
Expand Down Expand Up @@ -848,6 +906,13 @@ async function runnerAwaitSecrets(opts: RunnerCommandOptions): Promise<void> {
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)
}
Expand Down
35 changes: 35 additions & 0 deletions packages/cli/src/runner-daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading
Loading