git-workspace-service
v0.5.0
Published
Git workspace provisioning and credential management service with OAuth device flow authentication
Maintainers
Readme
git-workspace-service
Git workspace provisioning and credential management service. Handles cloning repositories, managing branches, credentials, and PR creation.
Features
- Workspace provisioning - Clone repos, create branches, configure git
- Git Worktrees - Fast parallel workspaces with shared .git directory
- Credential management - Secure credential handling with TTL and revocation; tokens are never stored in workspaces (see Security)
- Multiple providers - Support for GitHub, GitLab, Bitbucket, Azure DevOps
- GitHub App support - First-class GitHub App authentication
- OAuth Device Flow - Interactive authentication for CLI/agents (RFC 8628)
- Token caching - Encrypted persistent storage for OAuth tokens
- User credentials - Support for PAT, OAuth tokens, and SSH
- Fine-grained permissions - Control what agents can do
- Branch naming - Automatic branch naming with execution context
- PR & Issue management - Create PRs, manage issues, add comments
- Event system - Subscribe to workspace lifecycle events
- TypeScript-first - Full type definitions included
Installation
npm install git-workspace-service
# or
pnpm add git-workspace-serviceFor GitHub App support, also install:
npm install @octokit/rest @octokit/auth-appQuick Start
import {
WorkspaceService,
CredentialService,
GitHubProvider,
} from 'git-workspace-service';
// Set up credential service with GitHub provider
const githubProvider = new GitHubProvider({
appId: process.env.GITHUB_APP_ID!,
privateKey: process.env.GITHUB_PRIVATE_KEY!,
});
await githubProvider.initialize();
const credentialService = new CredentialService({
defaultTtlSeconds: 3600,
maxTtlSeconds: 7200,
});
credentialService.registerProvider(githubProvider);
// Create workspace service
const workspaceService = new WorkspaceService({
config: {
baseDir: '/tmp/workspaces',
// Optional: where single-use credential helpers live (must be outside
// baseDir, ideally unreadable by agents). Defaults to os.tmpdir().
credentialHelperDir: '/var/run/my-service/git-credentials',
},
credentialService,
});
await workspaceService.initialize();
// Provision a workspace
const workspace = await workspaceService.provision({
repo: 'https://github.com/owner/repo',
branchStrategy: 'feature_branch',
baseBranch: 'main',
execution: {
id: 'exec-123',
patternName: 'code-review',
},
task: {
id: 'task-456',
role: 'engineer',
slug: 'auth-feature',
},
});
console.log(`Workspace created at: ${workspace.path}`);
console.log(`Branch: ${workspace.branch.name}`);
// Do work in the workspace...
// Finalize with PR
const pr = await workspaceService.finalize(workspace.id, {
push: true,
createPr: true,
pr: {
title: 'Add authentication feature',
body: 'Implements OAuth login',
targetBranch: 'main',
labels: ['enhancement'],
},
cleanup: true,
});
console.log(`PR created: ${pr?.url}`);User-Provided Credentials
Instead of using GitHub App, users can provide their own PAT or OAuth token:
const workspace = await workspaceService.provision({
repo: 'https://github.com/owner/repo',
branchStrategy: 'feature_branch',
baseBranch: 'main',
execution: {
id: 'exec-123',
patternName: 'my-pattern',
},
task: {
id: 'task-456',
role: 'engineer',
},
userCredentials: {
type: 'pat',
token: 'ghp_xxxx...',
},
});Or use SSH authentication (relies on system SSH agent):
const workspace = await workspaceService.provision({
repo: '[email protected]:owner/repo.git',
// ...
userCredentials: {
type: 'ssh',
},
});OAuth Device Flow
For CLI applications and AI agents, use the OAuth Device Code Flow for interactive authentication:
import {
CredentialService,
OAuthDeviceFlow,
FileTokenStore,
DEFAULT_AGENT_PERMISSIONS,
} from 'git-workspace-service';
// Set up token store for persistent caching (with encryption)
const tokenStore = new FileTokenStore({
directory: '~/.myapp/tokens',
encryptionKey: process.env.TOKEN_ENCRYPTION_KEY,
});
// Create credential service with OAuth support
const credentialService = new CredentialService({
tokenStore,
oauth: {
clientId: process.env.GITHUB_CLIENT_ID!,
permissions: DEFAULT_AGENT_PERMISSIONS,
promptEmitter: {
onAuthRequired(prompt) {
console.log(`Visit: ${prompt.verificationUri}`);
console.log(`Enter code: ${prompt.userCode}`);
},
onAuthComplete(result) {
if (result.success) {
console.log('Authenticated!');
}
},
},
},
});
// Request credentials - will use cached token or trigger device flow
const credential = await credentialService.getCredentials({
repo: 'https://github.com/owner/repo',
access: 'write',
context: {
executionId: 'exec-123',
taskId: 'task-456',
reason: 'Code review',
},
});Credential Priority
When requesting credentials, the service checks sources in this order:
- User-provided - PAT, OAuth token, or SSH credentials passed directly
- Cached OAuth - Valid token from TokenStore (with auto-refresh)
- Provider - GitHub App or other registered provider
- OAuth Device Flow - Interactive authentication (if configured)
Fine-Grained Permissions
Control what agents can do with AgentPermissions:
import { DEFAULT_AGENT_PERMISSIONS } from 'git-workspace-service';
const permissions = {
repositories: { type: 'selected', repos: ['owner/repo'] },
contents: 'write', // 'none' | 'read' | 'write'
pullRequests: 'write',
issues: 'read',
metadata: 'read',
canDeleteBranch: true,
canForcePush: false, // Dangerous operations off by default
canDeleteRepository: false,
canAdminister: false,
};Git Worktrees
For parallel work on the same repository, use worktrees instead of clones. Worktrees share the .git directory, making them faster to create and using less disk space.
┌──────────────────────────┬────────────────────────────────────────┐
│ Clone │ Worktree │
├──────────────────────────┼────────────────────────────────────────┤
│ Full .git copy each time │ Shared .git directory │
│ Slower for same repo │ Fast - just checkout │
│ More disk space │ Minimal disk │
│ Good for different repos │ Perfect for parallel work on SAME repo │
└──────────────────────────┴────────────────────────────────────────┘Creating a Worktree
// First, create a clone workspace (the parent)
const parent = await workspaceService.provision({
repo: 'https://github.com/owner/repo',
strategy: 'clone', // explicit, but this is the default
branchStrategy: 'feature_branch',
baseBranch: 'main',
execution: { id: 'exec-123', patternName: 'review' },
task: { id: 'task-1', role: 'architect' },
});
// Then create worktrees from it for parallel agents
const reviewerWorkspace = await workspaceService.provision({
repo: 'https://github.com/owner/repo',
strategy: 'worktree',
parentWorkspace: parent.id, // Required for worktrees
branchStrategy: 'feature_branch',
baseBranch: 'main',
execution: { id: 'exec-123', patternName: 'review' },
task: { id: 'task-2', role: 'reviewer' },
});
// Or use the convenience method
const testerWorkspace = await workspaceService.addWorktree(parent.id, {
branch: 'main',
execution: { id: 'exec-123', patternName: 'review' },
task: { id: 'task-3', role: 'tester' },
});Managing Worktrees
// List all worktrees for a parent
const worktrees = workspaceService.listWorktrees(parent.id);
// Remove a specific worktree
await workspaceService.removeWorktree(reviewerWorkspace.id);
// Cleanup parent also cleans up all its worktrees
await workspaceService.cleanup(parent.id);Worktree Benefits for Multi-Agent Systems
- Shared credentials: Worktrees reuse the parent's credential
- Faster provisioning: No network clone, just local checkout
- Less disk space: Single .git directory shared across all worktrees
- Isolated branches: Each worktree can work on a different branch
Event System
Subscribe to workspace lifecycle events:
const unsubscribe = workspaceService.onEvent((event) => {
switch (event.type) {
case 'workspace:provisioning':
console.log('Provisioning workspace...');
break;
case 'workspace:ready':
console.log('Workspace ready!');
break;
case 'credential:granted':
console.log(`Credential ${event.credentialId} granted`);
break;
case 'pr:created':
console.log(`PR created: ${event.data?.prUrl}`);
break;
case 'workspace:cleaned_up':
console.log('Workspace cleaned up');
break;
}
});
// Later, unsubscribe
unsubscribe();Branch Naming
Automatic branch naming with customizable prefix:
import { generateBranchName, parseBranchName } from 'git-workspace-service';
// Generate branch name
const branchName = generateBranchName({
executionId: 'exec-123',
role: 'engineer',
slug: 'auth-feature',
baseBranch: 'main',
});
// Result: 'parallax/exec-123/engineer-auth-feature'
// Parse branch name
const parsed = parseBranchName('parallax/exec-123/engineer-auth-feature');
// Result: { executionId: 'exec-123', role: 'engineer', slug: 'auth-feature' }Custom Branch Names
If you need a specific branch name (e.g. for deterministic naming or external conventions), pass branchName in the workspace config to bypass auto-generation:
const workspace = await workspaceService.provision({
repo: 'https://github.com/owner/repo',
branchStrategy: 'feature_branch',
baseBranch: 'main',
branchName: 'test/claude-nonce-abc123', // Used verbatim
execution: { id: 'exec-123', patternName: 'review' },
task: { id: 'task-456', role: 'engineer' },
});
console.log(workspace.branch.name); // 'test/claude-nonce-abc123'When branchName is set, isManagedBranch() may return false for the resulting branch — this is expected since custom names intentionally bypass the naming convention. The branch is still created from baseBranch and works with both clone and worktree strategies.
Security
Workspaces are designed to be handed to untrusted code, such as an AI coding agent. No credential is ever readable from inside a workspace.
How credentials are supplied
- Clone over a plain URL. Repositories are cloned from
https://host/owner/repo.git, with no token in the URL. So.git/config'soriginnever contains a credential. - One helper per command, outside the workspace. For every authenticated git command (clone, fetch, push), the service creates a private directory (mode
0700, frommkdtemp) underconfig.credentialHelperDir, which defaults toos.tmpdir()and must be outsidebaseDir. The directory holds the token (0600) and a smallshcredential helper. The helper is passed to git with top-levelgit -c credential.helper=...options, which git never writes to any config file. The directory is deleted when the command finishes, including when it fails, and oncleanup(). - Nothing in the workspace points at the token. After provisioning, the workspace and its
.git/configcontain no token, no credential helper and no helper path. The working tree is untouched: unlike earlier versions, there's no.git-workspace/directory and no.gitignoreedit. - No token in process listings. The token is never a command-line argument or an environment variable. Only the helper's path appears in
git's arguments, and that file is removed straight after the command. - The helper is scoped to the repository host. The helper only answers
getrequests forhttpsand the repository's exact host. Before the service's own helper is added, any other credential helper (system, global or repository-level) is reset, so no other helper sees or stores the token.
Running authenticated commands in agent-controlled repositories
push(), fetch(), finalize({ push: true }) and the fetch done when adding a worktree all run inside a repository the agent may have modified. Before using the token, the service takes these precautions:
- It refuses to run if the repository's own (local or worktree) config sets
credential.*,url.*(insteadOfrewrites),http.*(proxies, TLS settings, extra headers),include.*/includeIf.*,protocol.*,core.sshCommand,core.askPass,core.gitProxyorremote.*.{proxy,pushurl,vcs}. - It refuses to run if
origin's fetch or push URL no longer matches the URL the workspace was cloned from. - It disables hooks (
core.hooksPath) andcore.fsmonitorfor the command, so no repository-controlled code runs while the helper exists.
Input validation and redaction
- Every git command runs through
execFilewith an argument array. Nothing is interpolated into a shell. - Branch and base-branch names, including a custom
branchName, are validated withgit check-ref-formatrules plus a strict character allow-list (A-Z a-z 0-9 . _ / + @ -, with no leading-). Invalid names are rejected before anything touches disk. - Repository URLs must be
https://, SSH (git@host:owner/repo,ssh://...),host/owner/repoor GitHubowner/reposhorthand. URLs with embedded credentials (https://user:token@...),http://,file://,ext::and similar are rejected. - Error messages, events, progress messages and logs are passed through
redactSecrets(). This strips the workspace token, URL userinfo, well-known token formats (ghp_…,github_pat_…,glpat-…) andAuthorizationheaders.
Consequences for callers
- Agents can't push or fetch private repositories on their own. Inside the workspace,
git pushhas no credentials, unless the host provides ambient ones such as an SSH agent or a system credential helper. Push through the service instead: useworkspaceService.push(id),workspaceService.fetch(id, ['main'])orfinalize({ push: true }). - Same-user processes can still reach the helper while it exists. The helper directory is private to the service's OS user. A process running as that same user could still read it during the brief window when a command is running. For strong isolation, run agents as a different OS user or in a sandbox that can't read
credentialHelperDir,os.tmpdir()or the service's environment. - Tokens aren't refreshed automatically. The token captured at provisioning is used for later pushes, so a short-lived token (such as a GitHub App installation token, which lasts 1 hour) may expire before
finalize().
API Reference
WorkspaceService
class WorkspaceService {
constructor(options: WorkspaceServiceOptions);
// Initialize the service
initialize(): Promise<void>;
// Provision a new workspace
provision(config: WorkspaceConfig): Promise<Workspace>;
// Finalize workspace (push, create PR, cleanup)
finalize(workspaceId: string, options: WorkspaceFinalization): Promise<PullRequestInfo | void>;
// Push the workspace branch to origin with the workspace's credential
push(workspaceId: string, options?: { force?: boolean; setUpstream?: boolean }): Promise<void>;
// Fetch from origin with the workspace's credential
fetch(workspaceId: string, refs?: string[]): Promise<void>;
// Get workspace by ID
get(workspaceId: string): Workspace | null;
// Get all workspaces for an execution
getForExecution(executionId: string): Workspace[];
// Clean up a workspace
cleanup(workspaceId: string): Promise<void>;
// Clean up all workspaces for an execution
cleanupForExecution(executionId: string): Promise<void>;
// Subscribe to events
onEvent(handler: WorkspaceEventHandler): () => void;
}CredentialService
class CredentialService {
constructor(options?: CredentialServiceOptions);
// Register a provider adapter
registerProvider(provider: GitProviderAdapter): void;
// Get credentials for a repository
getCredentials(request: GitCredentialRequest): Promise<GitCredential>;
// Revoke a credential
revokeCredential(grantId: string): Promise<void>;
// Revoke all credentials for an execution
revokeForExecution(executionId: string): Promise<number>;
// Check if a credential is valid
isValid(grantId: string): boolean;
// Get grant info
getGrant(grantId: string): Promise<CredentialGrant | null>;
// Get all grants for an execution
getGrantsForExecution(executionId: string): Promise<CredentialGrant[]>;
}GitHubProvider
class GitHubProvider implements GitProviderAdapter {
constructor(config: GitHubProviderConfig, logger?: GitHubProviderLogger);
// Initialize and fetch installations
initialize(): Promise<void>;
// Register an installation
registerInstallation(installationId: number): Promise<GitHubAppInstallation>;
// Get credentials for a repo
getCredentialsForRepo(owner: string, repo: string, access: 'read' | 'write', ttlSeconds?: number): Promise<GitCredential>;
// Create a PR
createPullRequestForRepo(owner: string, repo: string, options: { title, body, head, base, draft?, labels?, reviewers? }): Promise<PullRequestInfo>;
// Delete a branch
deleteBranch(owner: string, repo: string, branch: string): Promise<void>;
// List managed branches
listManagedBranches(owner: string, repo: string, prefix?: string): Promise<string[]>;
}OAuthDeviceFlow
class OAuthDeviceFlow {
constructor(config: OAuthDeviceFlowConfig, logger?: OAuthDeviceFlowLogger);
// Start device flow and wait for authorization
authorize(): Promise<OAuthToken>;
// Request a device code (step 1)
requestDeviceCode(): Promise<DeviceCodeResponse>;
// Poll for token (step 2)
pollForToken(deviceCode: DeviceCodeResponse): Promise<OAuthToken>;
// Refresh an expired token
refreshToken(refreshToken: string): Promise<OAuthToken>;
}TokenStore
// Abstract base class
abstract class TokenStore {
save(provider: GitProvider, token: OAuthToken): Promise<void>;
get(provider: GitProvider): Promise<OAuthToken | null>;
clear(provider?: GitProvider): Promise<void>;
list(): Promise<GitProvider[]>;
isExpired(token: OAuthToken): boolean;
needsRefresh(token: OAuthToken): boolean;
}
// In-memory store (for testing)
class MemoryTokenStore extends TokenStore { }
// File-based store with encryption
class FileTokenStore extends TokenStore {
constructor(options?: {
directory?: string; // Default: ~/.parallax/tokens
encryptionKey?: string; // AES-256-CBC encryption
});
}Event Types
| Event | Description |
|-------|-------------|
| workspace:provisioning | Workspace provisioning started |
| workspace:ready | Workspace is ready for use |
| workspace:error | Workspace provisioning failed |
| workspace:finalizing | Workspace finalization started |
| workspace:cleaned_up | Workspace has been cleaned up |
| worktree:added | Git worktree added to parent |
| worktree:removed | Git worktree removed |
| credential:granted | Credential was granted |
| credential:revoked | Credential was revoked |
| pr:created | Pull request was created |
| pr:merged | Pull request was merged |
License
MIT
