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
24 changes: 18 additions & 6 deletions docs/byom-worker-admission.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ 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.

Waiting uses the caller's existing absolute dispatch deadline. It does not reset
or extend execution timeouts. Disconnecting or cancelling removes the waiting
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.

Expand All @@ -15,12 +19,20 @@ binding and workspace operation. A waiting request cannot migrate to a replaceme
worker. Existing execution acknowledgement, fencing, settlement and quarantine
rules remain responsible for the active assignment.

This is compatible with existing workers and requires only a Code API update.
This is compatible with existing workers: assignments retain the same absolute
deadline and server-relative timing fields. Store callers that omit the new
internal `executionTimeoutMs` argument retain their existing absolute-deadline behavior.
Existing workers still execute one assignment at a time. Parallel execution across
workspaces requires separate lease claims and isolated native sandbox contexts;
this admission change does not advertise that capability. Queue time and execution
time currently share the HTTP request deadline. Separate budgets require a matching
LibreChat client change so that the client does not disconnect while waiting.
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.

Focused regression coverage lives in `service/src/bridge/admission.test.ts` and
`service/src/bridge/worker-admission.test.ts`.
41 changes: 38 additions & 3 deletions packages/code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,45 @@ bubblewrap plus seccomp on Linux, and the SRT restricted-account helper on
Windows. Startup fails before worker registration when the platform or its
dependencies are unavailable. There is no unsandboxed command fallback.

The bridge worker remains outside the sandbox so it can maintain its outbound
Code API connection. Each command and its descendants run inside SRT with:
The native SRT manager owns process-global policy, proxy, and cleanup state.
Only one sandbox instance may own a manager, and that instance accepts one
command at a time. Overlapping calls fail before a second command starts;
they are not queued inside the sandbox. `close()` waits for the active command
and initialization before resetting the manager and removing scratch. A failed
reset keeps ownership fenced until a later `close()` succeeds. Independent
native workspaces need separate worker processes, not multiple instances of
the default manager in one process. This lifecycle guard does not enable
parallel assignments on a single bridge worker.

The CLI hosts the native manager in a persistent, dedicated Node executor
process. It does not inherit the bridge credential, arbitrary host environment,
or Node loader/debugger options. Workspace policy and per-command masked
credentials travel over private parent/child IPC, never command-line arguments.
The bridge retains pairing and GitHub App identity management. Cancellation is
addressed to the active command; executor loss after dispatch is treated as an
uncertain mutation and is never automatically replayed. Restarting a worker
still requires its existing quarantine checks. Native platform limitations on
hard descendant teardown continue to apply.

Embedding applications can use `NativeProcessWorkspaceCommandSandbox` from
`@librechat/code` for separate native managers in one host application, with
`prepare()`, `execute()`, and `close()`. Each instance is serial and must be
closed by its owner. The bridge scheduler remains serial until negotiated
execution slots and workspace-scoped quarantine are supported end to end.

- write access restricted to the one canonical registered workspace;
The bridge worker remains outside the sandbox so it can maintain its outbound
Code API connection. On macOS and Linux, each worker process creates an
owner-only scratch directory and grants SRT access to that exact directory
without opening the host temporary-directory root. Commands receive it through
`TMPDIR`, and orderly worker shutdown removes it. SRT's shared compatibility
scratch path is explicitly denied. Windows uses the restricted SRT account's
isolated profile and temporary directory instead. A workspace registration is
rejected if it sits inside SRT's shared scratch path or is broad enough to
contain worker scratch storage. Each command and its descendants run inside
SRT with:

- write access restricted to the one canonical registered workspace and the
worker's private scratch directory;
- read access denied to the worker's home directory except for that workspace;
- paired identity and mutation-quarantine files explicitly denied;
- `LIBRECHAT_CODE_*` and nonessential inherited environment variables removed;
Expand Down
5 changes: 3 additions & 2 deletions packages/code/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
EndpointRuntimeSupervisor,
} from './runtime.js';
import { RuntimeWorkspaceCommandSandbox } from './workspace-runtime.js';
import { NativeSrtWorkspaceCommandSandbox } from './native-sandbox.js';
import { NativeProcessWorkspaceCommandSandbox } from './native-process.js';
import {
GITHUB_ALLOWED_DOMAINS,
GITHUB_CREDENTIAL_ENV_NAME,
Expand Down Expand Up @@ -659,7 +659,7 @@ async function run(
});
const nativeCommandSandbox =
allowWorkspaceCommands && commandSandboxMode === 'native-srt'
? new NativeSrtWorkspaceCommandSandbox({
? new NativeProcessWorkspaceCommandSandbox({
workspaceRoot: canonicalWorkerDirectory!,
protectedPaths: [
identityPath,
Expand Down Expand Up @@ -738,6 +738,7 @@ async function run(
await github.provider?.getCredential(controller.signal);
await nativeCommandSandbox?.prepare();
} catch (error) {
await nativeCommandSandbox?.close().catch(() => undefined);
await fileRelaySupervisor?.stop().catch(() => undefined);
throw error;
}
Expand Down
1 change: 1 addition & 0 deletions packages/code/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export * from './runtime.js';
export * from './workspace.js';
export * from './workspace-runtime.js';
export * from './native-sandbox.js';
export * from './native-process.js';
export * from './github.js';
export * from './worker.js';
39 changes: 39 additions & 0 deletions packages/code/src/native-process-child.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import { fork } from 'node:child_process';
import test from 'node:test';
import { nativeExecutorEnvironment } from './native-process.js';

for (const signal of ['SIGINT', 'SIGHUP', 'SIGTERM'] as const) {
test(
`executor routes ${signal} through shutdown rather than default signal exit`,
{ skip: process.platform === 'win32', timeout: 10_000 },
async (t) => {
const child = fork(
new URL('./native-process-child.js', import.meta.url),
[],
{
execArgv: [],
env: nativeExecutorEnvironment(process.env),
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
},
);
t.after(() => {
child.kill('SIGKILL');
});
const exited = new Promise<{
code: number | null;
signal: NodeJS.Signals | null;
}>((resolve, reject) => {
child.once('error', reject);
child.once('exit', (code, exitSignal) =>
resolve({ code, signal: exitSignal }),
);
});
// An invalid-state reply proves module initialization and signal-handler
// installation finished without requiring platform SRT dependencies.
child.once('message', () => child.kill(signal));
child.send({ id: 'startup-probe', type: 'probe' });
assert.deepEqual(await exited, { code: 1, signal: null });
},
);
}
111 changes: 111 additions & 0 deletions packages/code/src/native-process-child.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { NativeSrtWorkspaceCommandSandbox } from './native-sandbox.js';
import { WorkspaceToolError } from './workspace.js';
import type { NativeSrtWorkspaceCommandSandboxOptions } from './native-sandbox.js';
import type { WorkspaceExecuteCommandRequest } from './protocol.js';

// This entrypoint is private to a forked trusted executor. No HTTP listener,
// argv credentials, bridge token, or persisted pairing material is required.
let sandbox: NativeSrtWorkspaceCommandSandbox | undefined;
let active: { id: string; controller: AbortController } | undefined;
let busy = false;
let credentials: Record<string, string> = {};
let wrappedCommand: string | undefined;

if (!process.send) throw new Error('Native executor requires IPC');
function reply(message: object): void {
if (!process.connected) return;
try {
process.send?.({ ...message, fatal: shuttingDown }, () => undefined);
} catch {
/* Parent was lost. */
}
}
let shuttingDown = false;
const shutdown = () => {
if (shuttingDown) return;
shuttingDown = true;
active?.controller.abort();
void (sandbox?.close() ?? Promise.resolve()).finally(() => process.exit(1));
setTimeout(() => process.exit(1), 5000);
};
process.on('disconnect', shutdown);
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
process.on('SIGHUP', shutdown);
process.on('message', async (raw: unknown) => {
if (shuttingDown) return;
const message = raw as {
id: string;
type: string;
options: Omit<
NativeSrtWorkspaceCommandSandboxOptions,
'maskedEnvironment'
> & {
variables?: NonNullable<
NativeSrtWorkspaceCommandSandboxOptions['maskedEnvironment']
>['variables'];
};
request: WorkspaceExecuteCommandRequest;
credentials?: Record<string, string>;
wrappedCommand?: string;
};
if (!message || typeof message.id !== 'string') return;
if (message.type === 'cancel') {
if (active?.id === message.id) active.controller.abort();
return;
}
if (busy) return;
busy = true;
try {
let result: unknown;
if (message.type === 'prepare' && !sandbox) {
const { variables, ...options } = message.options;
sandbox = new NativeSrtWorkspaceCommandSandbox({
...options,
...(variables
? {
maskedEnvironment: {
variables,
async resolve() {
return credentials;
},
wrapCommand(command) {
return wrappedCommand ?? command;
},
},
}
: {}),
});
await sandbox.prepare();
} else if (message.type === 'execute' && sandbox) {
active = { id: message.id, controller: new AbortController() };
credentials = message.credentials ?? {};
wrappedCommand = message.wrappedCommand;
result = await sandbox.execute(message.request, active.controller.signal);
} else if (message.type === 'close' && sandbox) {
await sandbox.close();
} else throw new Error('Invalid executor state');
reply({ id: message.id, ok: true, result });
} catch (error) {
reply({
id: message.id,
ok: false,
code:
error instanceof WorkspaceToolError
? error.code
: 'COMMAND_UNAVAILABLE',
...(error instanceof WorkspaceToolError
? { errorMessage: error.message.slice(0, 1024) }
: {}),
mutation:
error instanceof WorkspaceToolError
? error.mutationMayHaveCommitted
: true,
});
} finally {
active = undefined;
credentials = {};
wrappedCommand = undefined;
busy = false;
}
});
Loading