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
19 changes: 14 additions & 5 deletions docs/remote-bridge/worker-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,13 +323,21 @@ Configure the worker, preferably in a separate service drop-in:
```ini
[Service]
Environment=LIBRECHAT_CODE_GITHUB_APP_ID=12345
Environment=LIBRECHAT_CODE_GITHUB_INSTALLATION_ID=67890
Environment=LIBRECHAT_CODE_GITHUB_PRIVATE_KEY_FILE=/home/librechat-code/.config/librechat-code/github-app.pem
```

The trusted worker mints short-lived installation tokens. Sandboxed commands
receive masked Git/`gh` credentials only for the configured GitHub hosts; the
token is not written to the repository, remote URL, or Git configuration.
Install the same App separately on every personal account or organization the
worker is allowed to use. The trusted worker resolves the correct installation
from the repository containing each command's working directory, then mints and
caches a repository-scoped token. Cross-repository work therefore does not
require changing an installation ID or restarting the worker. Set
`LIBRECHAT_CODE_GITHUB_INSTALLATION_ID` only as a legacy fixed-installation
fallback.

Sandboxed commands receive masked Git/`gh` credentials only for the configured
GitHub hosts; the token is not written to the repository, remote URL, or Git
configuration. Git commits receive the App bot's canonical no-reply identity so
GitHub renders the bot profile and avatar.

## 10. Run under systemd

Expand Down Expand Up @@ -518,7 +526,8 @@ command, cancellation, or settlement whose effects may be incomplete.
- [ ] Pairing is principal-bound and the identity file is private.
- [ ] Definitions are outside roots and immutable to sandboxed tools.
- [ ] Workspace ancestors are not group/other writable.
- [ ] GitHub App is optional, least-privilege, and installed only where needed.
- [ ] GitHub App is optional, least-privilege, and installed on every account
the worker is expected to use.
- [ ] Approval policy remains enforced independently of worker capability.
- [ ] Service manager uses the intended executable and configuration.
- [ ] Worker is online, ready, and advertises the expected workspace.
Expand Down
14 changes: 12 additions & 2 deletions packages/code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,14 +283,24 @@ repositories the agent may access:

```bash
LIBRECHAT_CODE_GITHUB_APP_ID=12345 \
LIBRECHAT_CODE_GITHUB_INSTALLATION_ID=67890 \
LIBRECHAT_CODE_GITHUB_PRIVATE_KEY_FILE=/secure/librechat-agent.pem \
librechat-code run --worker-dir /path/to/project --allow-workspace-commands
```

The private key must be an owner-only regular file outside the workspace. It is
read only by the trusted worker, which mints and refreshes short-lived
installation tokens. A personal access token is supported as a fallback with
installation tokens. At startup, the worker binds each explicitly admitted
workspace root to its Git repository. Commands in those independent roots can
use simultaneous installations on personal accounts and organizations without
being restarted or reconfigured, while a command cannot gain access by changing
its workspace's remote URL. Tokens are scoped and cached per repository. For
compatibility with deployments
that intentionally bind a worker to one installation, set the optional legacy
`LIBRECHAT_CODE_GITHUB_INSTALLATION_ID` fallback.

App-authenticated commits use the GitHub App bot's canonical no-reply identity,
so GitHub links them to the bot profile and avatar. A personal access token is
supported as a fallback with
`LIBRECHAT_CODE_GITHUB_TOKEN`, but the GitHub App is the safer default because
its repository access and permissions can be narrowly installed and revoked.
Native Windows credential storage is unavailable until native DACL removal and
Expand Down
59 changes: 58 additions & 1 deletion packages/code/src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { generateKeyPairSync } from 'node:crypto';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';

Expand Down Expand Up @@ -233,6 +237,59 @@ test('CLI validates GitHub App credentials before worker registration', () => {
assert.doesNotMatch(result.stderr, /fetch failed/);
});

test('CLI accepts repository-routed GitHub App authentication without a fixed installation', async (t) => {
const directory = await mkdtemp(join(tmpdir(), 'cli-github-routing-'));
t.after(() => rm(directory, { recursive: true, force: true }));
const privateKeyPath = join(directory, 'app.pem');
const preload = join(directory, 'fetch.mjs');
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
await writeFile(
privateKeyPath,
privateKey.export({ type: 'pkcs8', format: 'pem' }),
{ mode: 0o600 },
);
await writeFile(
preload,
`
globalThis.fetch = async (input) => {
const url = String(input);
if (url.endsWith('/app')) return Response.json({ slug: 'lia-by-librechat' });
if (url.endsWith('/users/lia-by-librechat%5Bbot%5D')) {
return Response.json({ id: 328778573, login: 'lia-by-librechat[bot]', type: 'Bot' });
}
throw new Error('test stopped after GitHub App validation');
};
`,
);
const result = spawnSync(
process.execPath,
[
'--import',
preload,
fileURLToPath(new URL('./cli.js', import.meta.url)),
],
{
encoding: 'utf8',
timeout: 10_000,
env: {
...process.env,
LIBRECHAT_CODE_URL: 'http://127.0.0.1:1/v1',
LIBRECHAT_CODE_WORKER_TOKEN: 'worker-secret',
LIBRECHAT_CODE_WORKER_ID: 'engineering-vm',
LIBRECHAT_CODE_WORKER_DIR: directory,
LIBRECHAT_CODE_ALLOW_WORKSPACE_COMMANDS: 'true',
LIBRECHAT_CODE_GITHUB_TOKEN: undefined,
LIBRECHAT_CODE_GITHUB_APP_ID: '123',
LIBRECHAT_CODE_GITHUB_INSTALLATION_ID: undefined,
LIBRECHAT_CODE_GITHUB_PRIVATE_KEY_FILE: privateKeyPath,
},
},
);
assert.notEqual(result.status, 0);
assert.doesNotMatch(result.stderr, /GitHub App authentication requires/);
assert.doesNotMatch(result.stderr, /installation ID/i);
});

test('CLI requires a runtime image for Docker supervision', () => {
const result = spawnSync(
process.execPath,
Expand Down Expand Up @@ -449,6 +506,6 @@ test('CLI host-only enterprise configuration sends App JWTs to GHES, never GitHu
},
});
assert.equal(result.status, 1, result.stderr);
assert.match(result.stderr, /GITHUB_REQUEST:https:\/\/github\.example\.test\/api\/v3\/app\/installations\/456\/access_tokens/);
assert.match(result.stderr, /GITHUB_REQUEST:https:\/\/github\.example\.test\/api\/v3\/app/);
assert.doesNotMatch(result.stderr, /GITHUB_REQUEST:https:\/\/api\.github\.com/);
});
52 changes: 44 additions & 8 deletions packages/code/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ import type { LocalWorkspaceConfig } from './workspace.js';
import {
GITHUB_ALLOWED_DOMAINS,
GitHubAppCredentialProvider,
gitHubRepositoryForAdmittedDirectory,
gitHubRepositoryForDirectory,
gitHubCommandCredentialEnvironment,
gitHubMaskedCredentialVariables,
StaticGitHubCredentialProvider,
Expand Down Expand Up @@ -149,6 +151,7 @@ function githubCredentials(): {
host: string;
privateKeyPath?: string;
mode?: 'app' | 'token';
repositoryRouting?: boolean;
policyIdentity: string;
} {
const token = nonEmpty(process.env.LIBRECHAT_CODE_GITHUB_TOKEN);
Expand All @@ -161,9 +164,9 @@ function githubCredentials(): {
);
const appValues = [appId, installationId, privateKeyPath];
const hasApp = appValues.some(Boolean);
if (hasApp && !appValues.every(Boolean)) {
if (hasApp && (!appId || !privateKeyPath)) {
throw new Error(
'GitHub App authentication requires LIBRECHAT_CODE_GITHUB_APP_ID, LIBRECHAT_CODE_GITHUB_INSTALLATION_ID, and LIBRECHAT_CODE_GITHUB_PRIVATE_KEY_FILE',
'GitHub App authentication requires LIBRECHAT_CODE_GITHUB_APP_ID and LIBRECHAT_CODE_GITHUB_PRIVATE_KEY_FILE; LIBRECHAT_CODE_GITHUB_INSTALLATION_ID is an optional legacy fallback',
);
}
if (hasApp && token) {
Expand Down Expand Up @@ -203,6 +206,7 @@ function githubCredentials(): {
return {
host,
mode: 'app',
repositoryRouting: !installationId,
policyIdentity: gitHubAuthenticationPolicyIdentity({
mode: 'app',
host,
Expand All @@ -212,7 +216,7 @@ function githubCredentials(): {
privateKeyPath,
provider: new GitHubAppCredentialProvider({
appId: appId!,
installationId: installationId!,
installationId,
privateKeyPath: privateKeyPath!,
host,
apiUrl,
Expand Down Expand Up @@ -715,6 +719,19 @@ async function run(
}),
]),
);
// Bind credentials to immutable, explicitly admitted roots. The repository
// remote is operator input at startup, never an authorization input that a
// sandboxed command may change for its next invocation.
const admittedGitHubRepositories = github.provider && github.repositoryRouting
? new Map(
await Promise.all(
roots.map(async root => [
root.root,
await gitHubRepositoryForDirectory(root.root, github.host),
] as const),
),
)
: undefined;
const localWorkspaceTools = workerDirectory
? await LocalWorkspaceTools.create({
workspaces: roots,
Expand Down Expand Up @@ -928,18 +945,34 @@ async function run(
...(github.provider
? {
maskedEnvironment: {
variables: gitHubMaskedCredentialVariables(github.host),
async resolve(signal?: AbortSignal) {
variables: gitHubMaskedCredentialVariables(
github.host,
),
async resolve(signal?: AbortSignal, cwd?: string) {
const repository = cwd && admittedGitHubRepositories
? gitHubRepositoryForAdmittedDirectory(
cwd,
admittedGitHubRepositories,
)
: undefined;
if (!repository && github.repositoryRouting) {
return {};
}
return gitHubCommandCredentialEnvironment(
await github.provider!.getCredential(signal),
await github.provider!.getCredential(signal, repository),
github.host,
);
},
wrapCommand(command: string, platform: NodeJS.Platform) {
wrapCommand(
command: string,
platform: NodeJS.Platform,
environment: Readonly<Record<string, string>>,
) {
return wrapGitHubCredentialCommand(
command,
github.host,
platform,
environment,
);
},
},
Expand Down Expand Up @@ -1022,7 +1055,10 @@ async function run(
);
}
try {
await github.provider?.getCredential(controller.signal);
await github.provider?.validate?.(controller.signal);
if (github.provider && !github.provider.validate) {
await github.provider.getCredential(controller.signal);
}
await nativeCommandSandbox?.prepare();
for (const environment of option(args, '--reset-workspace-quarantine') == null ? environments : []) {
const setup = environment.definition.setup;
Expand Down
Loading
Loading