From d86d42b47c9a83e5385adb222fb65567afc0768d Mon Sep 17 00:00:00 2001 From: Alexis Sellier Date: Thu, 11 Jun 2026 10:01:23 +0200 Subject: [PATCH] typescript: add git-credential-code-storage helper Installs a Git credential helper bin that keeps token-free HTTPS remote URLs in Git config and mints a fresh short-lived JWT each time Git asks for credentials. Configure with: git config credential.https://ORG.code.storage.helper code-storage git config credential.https://ORG.code.storage.useHttpPath true The helper reads PIERRE_PRIVATE_KEY_FILE or PIERRE_PRIVATE_KEY and PIERRE_TOKEN_TTL (default 1 hour). --- packages/code-storage-typescript/README.md | 44 +++- packages/code-storage-typescript/package.json | 3 + .../src/git-credential-code-storage.ts | 242 ++++++++++++++++++ .../tests/git-credential-code-storage.test.ts | 224 ++++++++++++++++ .../code-storage-typescript/tsconfig.json | 1 + .../tsconfig.tsup.json | 1 + .../code-storage-typescript/tsup.config.ts | 2 +- skills/code-storage/SKILL.md | 24 ++ 8 files changed, 538 insertions(+), 3 deletions(-) create mode 100755 packages/code-storage-typescript/src/git-credential-code-storage.ts create mode 100644 packages/code-storage-typescript/tests/git-credential-code-storage.test.ts diff --git a/packages/code-storage-typescript/README.md b/packages/code-storage-typescript/README.md index bb7b2c3..bcaaa80 100644 --- a/packages/code-storage-typescript/README.md +++ b/packages/code-storage-typescript/README.md @@ -123,6 +123,45 @@ const readOnlyUrl = await repo.getRemoteURL({ // - 'repo:write' - Create a repository ``` +### Git HTTPS Credential Helper + +Installing `@pierre/storage` also installs `git-credential-code-storage`, a Git +credential helper that lets you keep a plain HTTPS remote URL in Git config and +mints a fresh JWT each time Git asks for credentials: + +```bash +export PIERRE_PRIVATE_KEY_FILE=/path/to/key.pem +git remote set-url origin https://your-name.code.storage/repo-id.git +git config credential.https://your-name.code.storage.helper code-storage +git config credential.https://your-name.code.storage.useHttpPath true +``` + +With this setup, `.git/config` stores only the token-free URL: + +```text +url = https://your-name.code.storage/repo-id.git +``` + +At request time, the helper returns username `t` and a newly minted JWT as the +password. `useHttpPath=true` is required so Git passes the repository path to +the helper. The helper reads: + +- `PIERRE_PRIVATE_KEY_FILE` or `PIERRE_PRIVATE_KEY` for the signing key. +- `PIERRE_TOKEN_TTL` for the minted JWT TTL in seconds, defaulting to 1 hour + (each Git operation requests fresh credentials). +- `PIERRE_DEBUG` to log token acquisition diagnostics to stderr (set to any + value except `0` or `false`). Minted tokens are never logged, and stdout + stays reserved for the Git credential protocol. + +Nested repo IDs, ephemeral remotes, and import remotes use the same paths as +authenticated HTTPS URLs: + +```bash +git remote set-url origin https://your-name.code.storage/team/project.git +git remote add ephemeral https://your-name.code.storage/repo-id+ephemeral.git +git remote add import https://your-name.code.storage/repo-id+import.git +``` + #### Ephemeral Branches For working with ephemeral branches (temporary branches isolated from the main @@ -1002,14 +1041,15 @@ interface RestoreCommitResult { ## Authentication The SDK uses JWT (JSON Web Tokens) for authentication. When you call -`getRemoteURL()`, it: +`getRemoteURL()` or configure the HTTPS credential helper, it: 1. Creates a JWT with your name, repository ID, and requested permissions 2. Signs it with your key 3. Embeds it in the Git remote URL as the password The generated URLs are compatible with standard Git clients and include all -necessary authentication. +necessary authentication. The HTTPS credential helper keeps the token out of +Git config and mints a fresh token for each helper invocation. ## Error Handling diff --git a/packages/code-storage-typescript/package.json b/packages/code-storage-typescript/package.json index 897fb81..3130641 100644 --- a/packages/code-storage-typescript/package.json +++ b/packages/code-storage-typescript/package.json @@ -19,6 +19,9 @@ "default": "./dist/index.js" } }, + "bin": { + "git-credential-code-storage": "./dist/git-credential-code-storage.js" + }, "files": [ "dist", "src" diff --git a/packages/code-storage-typescript/src/git-credential-code-storage.ts b/packages/code-storage-typescript/src/git-credential-code-storage.ts new file mode 100755 index 0000000..317e50d --- /dev/null +++ b/packages/code-storage-typescript/src/git-credential-code-storage.ts @@ -0,0 +1,242 @@ +#!/usr/bin/env node +import { readFileSync, realpathSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { GitStorage } from './index'; + +/** + * Default TTL for credentials minted by the helper. Git requests a fresh + * credential for every operation, so the token only needs to outlive a single + * fetch or push. Override with PIERRE_TOKEN_TTL (seconds). + */ +const DEFAULT_CREDENTIAL_TTL_SECONDS = 60 * 60; + +interface GitCredentialInput { + protocol?: string; + host?: string; + path?: string; +} + +type CredentialLog = (message: string) => void; + +const noLog: CredentialLog = () => {}; + +/** + * Builds a logger that writes diagnostic lines to stderr when PIERRE_DEBUG is + * set (to anything except '0' or 'false'). stdout carries the credential + * protocol Git reads, so logs must never be written there. Minted tokens are + * never logged. + */ +function createCredentialLogger( + env: NodeJS.ProcessEnv, + stderr: NodeJS.WriteStream +): CredentialLog { + const flag = env.PIERRE_DEBUG?.trim().toLowerCase(); + if (!flag || flag === '0' || flag === 'false') { + return noLog; + } + return (message) => { + stderr.write(`git-credential-code-storage: ${message}\n`); + }; +} + +/** + * Resolves a Git credential for a Code Storage HTTPS remote. + * + * Returns null when the request is not for a `*.code.storage` host so Git + * falls through to the next configured helper. Throws when the request is for + * Code Storage but cannot be served (missing path or signing key), since + * silently declining would make Git prompt for a password that does not exist. + */ +export async function getCodeStorageCredential( + input: GitCredentialInput, + env: NodeJS.ProcessEnv = process.env, + readFile: (path: string) => string = (path) => readFileSync(path, 'utf8'), + log: CredentialLog = noLog +): Promise<{ username: string; password: string } | null> { + if (input.protocol !== 'https' || !input.host) { + log('declining request: not an HTTPS remote with a host'); + return null; + } + + const name = parseOrganization(input.host); + if (!name) { + log(`declining request for ${input.host}: not a *.code.storage host`); + return null; + } + if (!input.path) { + throw new Error( + 'Code Storage credential helper requires credential.useHttpPath=true' + ); + } + + const repoId = parseRepoId(input.path); + if (!repoId) { + throw new Error(`Could not determine a repository id from path: ${input.path}`); + } + log( + env.PIERRE_PRIVATE_KEY + ? 'signing with private key from PIERRE_PRIVATE_KEY' + : `signing with private key from ${env.PIERRE_PRIVATE_KEY_FILE ?? '(unset)'}` + ); + + const key = env.PIERRE_PRIVATE_KEY ?? readPrivateKeyFile(env, readFile); + if (!key || key.trim() === '') { + throw new Error( + 'Set PIERRE_PRIVATE_KEY_FILE or PIERRE_PRIVATE_KEY before using the Code Storage credential helper' + ); + } + + const ttl = parsePositiveInteger(env.PIERRE_TOKEN_TTL, DEFAULT_CREDENTIAL_TTL_SECONDS); + log(`acquiring git:write+git:read token for repo ${repoId} (org ${name}, ttl ${ttl}s)`); + const storage = new GitStorage({ name, key }); + const remoteURL = await storage.repo({ id: repoId }).getRemoteURL({ + permissions: ['git:write', 'git:read'], + ttl, + }); + const url = new URL(remoteURL); + log(`token acquired for ${url.host}${url.pathname}`); + return { + username: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + }; +} + +export async function runGitCredentialCodeStorage( + argv: string[], + stdin: NodeJS.ReadStream = process.stdin, + stdout: NodeJS.WriteStream = process.stdout, + env: NodeJS.ProcessEnv = process.env, + stderr: NodeJS.WriteStream = process.stderr +): Promise { + const log = createCredentialLogger(env, stderr); + // Git writes the credential description for every operation, so drain stdin + // before deciding anything to avoid EPIPE on the Git side. + const description = await readStdin(stdin); + + // 'store' and 'erase' are no-ops: credentials are minted on demand and + // never persisted. + if (argv[2] !== 'get') { + log(`ignoring '${argv[2] ?? ''}' operation: only 'get' mints credentials`); + return 0; + } + + const credential = await getCodeStorageCredential( + parseCredentialInput(description), + env, + undefined, + log + ); + if (!credential) { + return 0; + } + + stdout.write(`username=${credential.username}\npassword=${credential.password}\n\n`); + return 0; +} + +/** Parses the `key=value` credential description Git writes to stdin. */ +export function parseCredentialInput(input: string): GitCredentialInput { + const credential: GitCredentialInput = {}; + for (const line of input.split(/\r?\n/)) { + if (line === '') { + break; + } + const separator = line.indexOf('='); + if (separator <= 0) { + continue; + } + const key = line.slice(0, separator); + const value = line.slice(separator + 1); + if (key === 'protocol' || key === 'host' || key === 'path') { + credential[key] = value; + } + } + return credential; +} + +/** + * Extracts the organization from a `.code.storage` host, tolerating an + * explicit port. Returns null for any other host. + */ +function parseOrganization(host: string): string | null { + const match = /^(.+)\.code\.storage(?::\d+)?$/i.exec(host); + return match ? match[1] : null; +} + +/** + * Derives the repository id from the URL path of an authenticated Code + * Storage HTTPS remote. Ephemeral (`+ephemeral`) and import (`+import`) + * remotes use the base repository's credentials, so their suffixes are + * stripped. + */ +function parseRepoId(path: string): string { + let repoPath = path.replace(/^\/+/, '').replace(/\/+$/, ''); + if (repoPath.endsWith('.git')) { + repoPath = repoPath.slice(0, -'.git'.length); + } + repoPath = repoPath.replace(/\+(ephemeral|import)$/, ''); + return repoPath + .split('/') + .map((segment) => decodeURIComponent(segment)) + .join('/'); +} + +function readPrivateKeyFile( + env: NodeJS.ProcessEnv, + readFile: (path: string) => string +): string | undefined { + const keyFile = env.PIERRE_PRIVATE_KEY_FILE; + return keyFile && keyFile.trim() !== '' ? readFile(keyFile) : undefined; +} + +function parsePositiveInteger(value: string | undefined, fallback: number): number { + if (!value) { + return fallback; + } + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function readStdin(stdin: NodeJS.ReadStream): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + let input = ''; + stdin.setEncoding('utf8'); + stdin.on('data', (chunk) => { + input += chunk; + }); + stdin.on('end', () => { + resolve(input); + }); + stdin.on('error', reject); + return promise; +} + +/** + * npm installs bins as symlinks, and Node keeps the symlink path in + * process.argv[1] while import.meta.url points at the real file, so both + * sides must be resolved before comparing. + */ +function isMainModule(): boolean { + const entry = process.argv[1]; + if (!entry) { + return false; + } + try { + return realpathSync(entry) === fileURLToPath(import.meta.url); + } catch { + return false; + } +} + +if (isMainModule()) { + runGitCredentialCodeStorage(process.argv) + .then((code) => { + process.exitCode = code; + }) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`git-credential-code-storage: ${message}`); + process.exitCode = 1; + }); +} diff --git a/packages/code-storage-typescript/tests/git-credential-code-storage.test.ts b/packages/code-storage-typescript/tests/git-credential-code-storage.test.ts new file mode 100644 index 0000000..0a5e8aa --- /dev/null +++ b/packages/code-storage-typescript/tests/git-credential-code-storage.test.ts @@ -0,0 +1,224 @@ +import { PassThrough } from 'node:stream'; + +import { describe, expect, it } from 'vitest'; + +import { + getCodeStorageCredential, + parseCredentialInput, + runGitCredentialCodeStorage, +} from '../src/git-credential-code-storage'; + +const key = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgy3DPdzzsP6tOOvmo +rjbx6L7mpFmKKL2hNWNW3urkN8ehRANCAAQ7/DPhGH3kaWl0YEIO+W9WmhyCclDG +yTh6suablSura7ZDG8hpm3oNsq/ykC3Scfsw6ZTuuVuLlXKV/be/Xr0d +-----END PRIVATE KEY-----`; + +function decodeJwtPayload(jwt: string): Record { + const parts = jwt.split('.'); + if (parts.length !== 3) { + throw new Error('Invalid JWT format'); + } + return JSON.parse(Buffer.from(parts[1], 'base64url').toString()); +} + +describe('git-credential-code-storage', () => { + it('parses Git credential helper input', () => { + expect( + parseCredentialInput( + 'protocol=https\nhost=pierre.code.storage\npath=team/project.git\n\n' + ) + ).toEqual({ + protocol: 'https', + host: 'pierre.code.storage', + path: 'team/project.git', + }); + }); + + it('returns a username and JWT password for Code Storage HTTPS credentials', async () => { + const credential = await getCodeStorageCredential( + { + protocol: 'https', + host: 'pierre.code.storage', + path: 'team/project.git', + }, + { + PIERRE_PRIVATE_KEY: key, + PIERRE_TOKEN_TTL: '900', + } + ); + + expect(credential?.username).toBe('t'); + expect(credential?.password).toBeTruthy(); + const payload = decodeJwtPayload(credential!.password); + expect(payload.iss).toBe('pierre'); + expect(payload.repo).toBe('team/project'); + expect(payload.scopes).toEqual(['git:write', 'git:read']); + expect(Number(payload.exp) - Number(payload.iat)).toBe(900); + }); + + it('uses the base repo id for ephemeral and import HTTPS paths', async () => { + const ephemeral = await getCodeStorageCredential( + { + protocol: 'https', + host: 'pierre.code.storage', + path: 'project+ephemeral.git', + }, + { PIERRE_PRIVATE_KEY: key } + ); + const imported = await getCodeStorageCredential( + { + protocol: 'https', + host: 'pierre.code.storage', + path: 'project+import.git', + }, + { PIERRE_PRIVATE_KEY: key } + ); + + expect(decodeJwtPayload(ephemeral!.password).repo).toBe('project'); + expect(decodeJwtPayload(imported!.password).repo).toBe('project'); + }); + + it('ignores non-Code Storage credential requests', async () => { + await expect( + getCodeStorageCredential({ + protocol: 'https', + host: 'example.com', + path: 'repo.git', + }) + ).resolves.toBeNull(); + }); + + it('requires useHttpPath so the repo id is available', async () => { + await expect( + getCodeStorageCredential( + { + protocol: 'https', + host: 'pierre.code.storage', + }, + { PIERRE_PRIVATE_KEY: key } + ) + ).rejects.toThrow('credential.useHttpPath=true'); + }); + + it('strips an explicit port from the host', async () => { + const credential = await getCodeStorageCredential( + { + protocol: 'https', + host: 'pierre.code.storage:8443', + path: 'project.git', + }, + { PIERRE_PRIVATE_KEY: key } + ); + + expect(decodeJwtPayload(credential!.password).iss).toBe('pierre'); + }); + + it('reads the private key from PIERRE_PRIVATE_KEY_FILE', async () => { + const credential = await getCodeStorageCredential( + { + protocol: 'https', + host: 'pierre.code.storage', + path: 'project.git', + }, + { PIERRE_PRIVATE_KEY_FILE: '/key.pem' }, + (path) => { + expect(path).toBe('/key.pem'); + return key; + } + ); + + expect(credential?.password).toBeTruthy(); + }); + + it('fails loudly when no signing key is configured', async () => { + await expect( + getCodeStorageCredential( + { + protocol: 'https', + host: 'pierre.code.storage', + path: 'project.git', + }, + {} + ) + ).rejects.toThrow('PIERRE_PRIVATE_KEY_FILE or PIERRE_PRIVATE_KEY'); + }); +}); + +function makeStdin(description: string): NodeJS.ReadStream { + const stream = new PassThrough(); + stream.end(description); + return stream as unknown as NodeJS.ReadStream; +} + +function captureStream(): { stream: NodeJS.WriteStream; chunks: string[] } { + const chunks: string[] = []; + const stream = { + write(chunk: string) { + chunks.push(chunk); + return true; + }, + } as unknown as NodeJS.WriteStream; + return { stream, chunks }; +} + +describe('credential helper debug logging', () => { + const description = + 'protocol=https\nhost=pierre.code.storage\npath=project.git\n\n'; + + it('logs token acquisition to stderr when PIERRE_DEBUG is set', async () => { + const stdout = captureStream(); + const stderr = captureStream(); + + const code = await runGitCredentialCodeStorage( + ['node', 'git-credential-code-storage', 'get'], + makeStdin(description), + stdout.stream, + { PIERRE_PRIVATE_KEY: key, PIERRE_DEBUG: '1' }, + stderr.stream + ); + + expect(code).toBe(0); + const logs = stderr.chunks.join(''); + expect(logs).toContain( + 'acquiring git:write+git:read token for repo project (org pierre' + ); + expect(logs).toContain('token acquired'); + + // The minted token goes to stdout for Git; it must never be logged. + const password = /password=(\S+)/.exec(stdout.chunks.join(''))?.[1]; + expect(password).toBeTruthy(); + expect(logs).not.toContain(password!); + }); + + it('logs skipped non-get operations', async () => { + const stderr = captureStream(); + + const code = await runGitCredentialCodeStorage( + ['node', 'git-credential-code-storage', 'store'], + makeStdin(description), + captureStream().stream, + { PIERRE_DEBUG: '1' }, + stderr.stream + ); + + expect(code).toBe(0); + expect(stderr.chunks.join('')).toContain("ignoring 'store' operation"); + }); + + it('stays silent when PIERRE_DEBUG is unset, "0", or "false"', async () => { + for (const env of [{}, { PIERRE_DEBUG: '0' }, { PIERRE_DEBUG: 'false' }]) { + const stderr = captureStream(); + + await runGitCredentialCodeStorage( + ['node', 'git-credential-code-storage', 'get'], + makeStdin(description), + captureStream().stream, + { PIERRE_PRIVATE_KEY: key, ...env }, + stderr.stream + ); + + expect(stderr.chunks).toEqual([]); + } + }); +}); diff --git a/packages/code-storage-typescript/tsconfig.json b/packages/code-storage-typescript/tsconfig.json index d95b7ff..35b8acf 100644 --- a/packages/code-storage-typescript/tsconfig.json +++ b/packages/code-storage-typescript/tsconfig.json @@ -3,6 +3,7 @@ "include": ["src/**/*", "tsup.config.ts", "package.json"], "references": [], "compilerOptions": { + "lib": ["ES2024"], "outDir": "../../.moon/cache/types/packages/git-storage-sdk", "tsBuildInfoFile": "../../.moon/cache/types/packages/git-storage-sdk/.tsbuildinfo" } diff --git a/packages/code-storage-typescript/tsconfig.tsup.json b/packages/code-storage-typescript/tsconfig.tsup.json index fea75b8..f5bee80 100644 --- a/packages/code-storage-typescript/tsconfig.tsup.json +++ b/packages/code-storage-typescript/tsconfig.tsup.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.options.json", "include": ["src/**/*"], "compilerOptions": { + "lib": ["ES2024"], "composite": false, "incremental": false, "declaration": true, diff --git a/packages/code-storage-typescript/tsup.config.ts b/packages/code-storage-typescript/tsup.config.ts index 3f0e8a1..8ba74f4 100644 --- a/packages/code-storage-typescript/tsup.config.ts +++ b/packages/code-storage-typescript/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup'; export default defineConfig({ - entry: ['src/index.ts'], + entry: ['src/index.ts', 'src/git-credential-code-storage.ts'], format: ['cjs', 'esm'], dts: true, clean: true, diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index 27ce91d..48261f5 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -785,6 +785,30 @@ git add . && git commit -m "Agent changes" git push ``` +### Stable HTTPS Git remotes with credential helper + +The TypeScript package installs `git-credential-code-storage`, a Git credential +helper that keeps JWTs out of Git config while using normal HTTPS remotes: + +```bash +export PIERRE_PRIVATE_KEY_FILE=/path/to/key.pem +git remote set-url origin https://ORG.code.storage/REPO_ID.git +git config credential.https://ORG.code.storage.helper code-storage +git config credential.https://ORG.code.storage.useHttpPath true +git push origin main +``` + +The URL stored in `.git/config` is just +`https://ORG.code.storage/REPO_ID.git`. When Git asks for credentials, the helper +reads `PIERRE_PRIVATE_KEY_FILE` or `PIERRE_PRIVATE_KEY`, derives the org from the +host and the repo ID from the path, then returns `username=t` and a fresh +`git:write` + `git:read` JWT as the password. Set `PIERRE_TOKEN_TTL` to override +the default 1-hour token TTL, and `PIERRE_DEBUG=1` to log token-acquisition +diagnostics to stderr (tokens themselves are never logged). `useHttpPath=true` +is required; without it Git does not pass the repo path to the helper. +Ephemeral and import remote paths (`+ephemeral.git`, `+import.git`) resolve to +the base repository's credentials. + ## PROCEDURE 3: Ephemeral Branch Workflow (Preview Environment) **Goal:** Create isolated preview branch, work, then promote to persistent branch.