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
48 changes: 31 additions & 17 deletions docs/byom-worker-admission.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ The limit is 32 admitted requests per worker, including the active request. When
the limit is reached, the workspace endpoint returns HTTP 429 with
`WORKER_QUEUE_FULL`. A different worker has an independent admission queue.

The workspace HTTP endpoint allows at most 30 seconds for admission. After
admission and worker validation, a separate execution deadline starts. Commands
receive their requested timeout (30 seconds by default, up to five minutes),
capped by the operator's `JOB_TIMEOUT`, plus five seconds to settle the result.
Read/search/list operations receive up to 30 seconds, also capped by `JOB_TIMEOUT`.
Disconnecting or cancelling removes the waiting
request without cancelling the active assignment. Expired entries are pruned;
Redis key expiry also bounds state left by a crashed API process.
The Code API workspace HTTP endpoint allows up to the smaller of `JOB_TIMEOUT`
and five minutes for admission while the caller stays connected. After admission
and worker validation, a separate execution deadline starts. Commands receive
their requested timeout (30 seconds by default, up to five minutes), capped by
the operator's `JOB_TIMEOUT`, plus five seconds to settle the result. Other
operations receive up to 30 seconds, also capped by `JOB_TIMEOUT`.
Disconnecting or cancelling removes a waiting request without cancelling the
active assignment. Expired entries are pruned; Redis key expiry also bounds
state left by a crashed API process. Reservations derive their TTL at acquisition
from the remaining absolute deadline or a fresh execution budget; enqueued
assignment records use the final execution deadline, not the elapsed queue budget.

After admission, the API revalidates the worker incarnation, identity, tenant
binding and workspace operation. A waiting request cannot migrate to a replacement
Expand All @@ -26,13 +29,24 @@ Existing workers still execute one assignment at a time. Parallel execution acro
workspaces requires separate lease claims and isolated native sandbox contexts;
this admission change does not advertise that capability.

LibreChat must allow queue time plus execution/settlement time and five seconds
for HTTP delivery: 65 seconds for reads, 70 seconds for default commands, and
340 seconds for five-minute commands. Either side can be upgraded first. Older
clients still cancel at their earlier deadline; newer clients preserve errors from
older servers without retrying mutations. Both updates are needed for the full
waiting budget. Any reverse proxy request timeout must accommodate these totals.
The worker package does not need an update for the deadline change.
Clients and reverse proxies must allow queue time plus execution/settlement time
and five seconds for HTTP delivery. With the default five-minute `JOB_TIMEOUT`,
that is at least 335 seconds for non-command tools, 340 seconds for default
commands, and 610 seconds for five-minute commands. With a smaller `JOB_TIMEOUT`,
use `min(JOB_TIMEOUT, 300s)` for the queue, plus `min(JOB_TIMEOUT, 30s)` for other
operations or `min(JOB_TIMEOUT, requested command timeout) + 5s` for commands,
plus five seconds for delivery.

Focused regression coverage lives in `service/src/bridge/admission.test.ts` and
`service/src/bridge/worker-admission.test.ts`.
At the time of this change, LibreChat's `getWorkspaceToolTimeoutMs` still budgets
only 30 seconds for a single admission attempt (65/70/340 seconds in total).
Its `maxQueueWaitMs` is a retry horizon after a typed capacity rejection, **not**
a per-attempt HTTP timeout. Updating Code API alone therefore does not guarantee
the full wait. An earlier client, tool, or proxy timeout disconnects the request;
if work was already admitted, a mutation may have run and must not be blindly
retried. Match LibreChat's per-attempt timeout and each intermediary to the new
budget before relying on it. Existing workers do not need an update.

Focused regression coverage lives in `service/src/bridge/admission.test.ts`,
`service/src/bridge/worker-admission.test.ts`,
`service/src/bridge/concurrent-store.test.ts`, and
`service/src/workspace-tools/router.test.ts`.
6 changes: 6 additions & 0 deletions docs/remote-bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,12 @@ execution.
worker. The lower API or worker slot ceiling wins, and assignments sharing
the same workspace isolation key remain serialized while independent
conversation worktrees may run concurrently.
- Workspace tool admission waits for capacity up to the smaller of `JOB_TIMEOUT`
and five minutes while the HTTP caller remains connected. Disconnects cancel
waiting, and admitted work receives a separate execution budget. A shorter
client or proxy timeout can end the wait sooner; Code API does not receive an
absolute caller deadline. See [BYOM worker admission](../byom-worker-admission.md)
for the caller and proxy timeout requirements.
- Dynamic workers are fenced to their server-issued tenant before assignment.
- Each assignment has an absolute deadline, generation, and random lease token.
- Settlements with the wrong worker, generation, token, or expired deadline are
Expand Down
9 changes: 9 additions & 0 deletions docs/remote-bridge/worker-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,15 @@ filesystem root, but anyone able to alter a checkout's remote can select any
repository where the App is installed; keep the App's installation scope narrow.
Pass the checkout as the command working directory; changing directories only
inside the shell cannot change the token chosen before command launch.
For a trusted VM that needs to switch among repositories in the same installed
account or organization inside one command, set
`LIBRECHAT_CODE_GITHUB_TOKEN_SCOPE=installation`. The resolved installation
token covers only repositories and permissions GitHub granted to that App
installation. It refreshes after two minutes so newly approved permissions
become available without a worker restart. The default is `repository`.
Commands spanning different accounts or organizations must start in a checkout
from the target account or organization; a shell `cd` cannot switch the
installation chosen at command launch.
Set `LIBRECHAT_CODE_GITHUB_INSTALLATION_ID` only as a legacy
fixed-installation fallback; it cannot be combined with checkout routing.

Expand Down
21 changes: 19 additions & 2 deletions packages/code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,17 @@ installed**. Use this mode only where the machine operator trusts the VM and
the App's installation scope; the default `admitted` mode keeps the startup
binding. Checkout routing requires an App without a fixed installation ID.

On a trusted VM, `--github-token-scope installation` (or
`LIBRECHAT_CODE_GITHUB_TOKEN_SCOPE=installation`) mints one token for all
repositories GitHub grants to the resolved App installation. This lets a
command started in one checkout push to another repository in the same account
or organization, including through `cd` or `git -C`, and use organization
Projects. GitHub still enforces the installation's selected repositories and
permissions. Tokens are shared by installation, refreshed after two minutes,
and kept out of the sandbox's readable environment. The default remains
`repository`. A command crossing to another account or organization still
needs to start in a checkout belonging to that account or organization.

For compatibility with deployments that intentionally bind a worker to one
installation, set the optional legacy
`LIBRECHAT_CODE_GITHUB_INSTALLATION_ID` fallback.
Expand Down Expand Up @@ -780,11 +791,17 @@ Legacy requests without a conversation identity continue to use the selected
source root. Older Code API deployments do not negotiate the capability, so the
worker omits it until every request path understands the isolation boundary.

Admission waits at most 30 seconds. A `WORKSPACE_QUEUE_TIMEOUT` response (HTTP
503, `Retry-After: 1`) means the operation was not assigned or started; wait for
On an updated Code API, admission waits up to the smaller of `JOB_TIMEOUT` and
five minutes while the HTTP caller remains connected; older Code API versions
waited at most 30 seconds. A `WORKSPACE_QUEUE_TIMEOUT` response (HTTP 503,
`Retry-After: 1`) means the operation was not assigned or started; wait for
capacity before submitting it again. This is distinct from `ASSIGNMENT_EXPIRED`
or a transport timeout after dispatch, where execution may have occurred and
mutations must not be blindly retried. No automatic retry is added by this policy.
Align the client's per-attempt timeout and any proxy with the queue **plus**
execution budget before relying on the longer wait. See the
[BYOM worker admission guide](../../docs/byom-worker-admission.md) for the
current client limitation and the timeout calculations.

Keep the existing URL, pairing/identity, and network policy configuration.
The primary root keeps its configured workspace ID (default `primary`). Repeat
Expand Down
29 changes: 29 additions & 0 deletions packages/code/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,35 @@ test('CLI rejects checkout routing outside a trusted VM or without repository-sc
assert.match(invalid.stderr, /must be admitted or checkout/);
});

test('CLI permits installation-scoped GitHub tokens only for a trusted VM with routed App auth', () => {
const cli = fileURLToPath(new URL('./cli.js', import.meta.url));
const base = {
...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: process.cwd(),
LIBRECHAT_CODE_ALLOW_WORKSPACE_COMMANDS: 'true',
LIBRECHAT_CODE_GITHUB_TOKEN: undefined,
LIBRECHAT_CODE_GITHUB_APP_ID: '123',
LIBRECHAT_CODE_GITHUB_PRIVATE_KEY_FILE: '/does/not/matter',
LIBRECHAT_CODE_GITHUB_INSTALLATION_ID: undefined,
LIBRECHAT_CODE_GITHUB_TOKEN_SCOPE: 'installation',
};
const restricted = spawnSync(process.execPath, [cli], { encoding: 'utf8', env: base });
assert.match(restricted.stderr, /Installation-scoped GitHub tokens require the trusted-vm/);
const trusted = spawnSync(process.execPath, [cli], {
encoding: 'utf8',
env: { ...base, LIBRECHAT_CODE_COMMAND_POLICY_PRESET: 'trusted-vm' },
});
assert.doesNotMatch(trusted.stderr, /Installation-scoped GitHub tokens require/);
const fixed = spawnSync(process.execPath, [cli], {
encoding: 'utf8',
env: { ...base, LIBRECHAT_CODE_GITHUB_INSTALLATION_ID: '456' },
});
assert.match(fixed.stderr, /without a fixed installation ID/);
});

test('CLI requires a runtime image for Docker supervision', () => {
const result = spawnSync(
process.execPath,
Expand Down
23 changes: 22 additions & 1 deletion packages/code/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ function githubCredentials(args: string[]): {
mode?: 'app' | 'token';
repositoryRouting?: boolean;
checkoutRouting?: boolean;
installationTokenScope?: boolean;
policyIdentity: string;
} {
const token = nonEmpty(process.env.LIBRECHAT_CODE_GITHUB_TOKEN);
Expand Down Expand Up @@ -191,6 +192,18 @@ function githubCredentials(args: string[]): {
'Checkout GitHub repository routing requires a GitHub App without a fixed installation ID',
);
}
const tokenScope =
option(args, '--github-token-scope')?.trim().toLowerCase() ??
process.env.LIBRECHAT_CODE_GITHUB_TOKEN_SCOPE?.trim().toLowerCase() ??
'repository';
if (tokenScope !== 'repository' && tokenScope !== 'installation') {
throw new Error('GitHub token scope must be repository or installation');
}
if (tokenScope === 'installation' && (!hasApp || installationId)) {
throw new Error(
'Installation-scoped GitHub tokens require a GitHub App without a fixed installation ID',
);
}
const configuredHostValue = nonEmpty(
process.env.LIBRECHAT_CODE_GITHUB_HOST,
);
Expand Down Expand Up @@ -225,16 +238,19 @@ function githubCredentials(args: string[]): {
mode: 'app',
repositoryRouting: !installationId,
checkoutRouting: routing === 'checkout',
installationTokenScope: tokenScope === 'installation',
policyIdentity: gitHubAuthenticationPolicyIdentity({
mode: 'app',
host,
appId,
installationId,
}) + (routing === 'checkout' ? ':routing:checkout' : ''),
}) + (routing === 'checkout' ? ':routing:checkout' : '') +
(tokenScope === 'installation' ? ':scope:installation' : ''),
privateKeyPath,
provider: new GitHubAppCredentialProvider({
appId: appId!,
installationId,
tokenScope,
privateKeyPath: privateKeyPath!,
host,
apiUrl,
Expand Down Expand Up @@ -568,6 +584,11 @@ async function run(
'Checkout GitHub repository routing requires the trusted-vm command policy',
);
}
if (github.installationTokenScope && commandPolicy.preset !== 'trusted-vm') {
throw new Error(
'Installation-scoped GitHub tokens require the trusted-vm command policy',
);
}
const githubDomains = github.provider
? github.host === 'github.com'
? [...GITHUB_ALLOWED_DOMAINS]
Expand Down
Loading
Loading