Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/flat-monkeys-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"livekit-server-sdk": patch
---

Add `deployment` to `CreateDispatchOptions`
2 changes: 1 addition & 1 deletion packages/livekit-server-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
},
"dependencies": {
"@bufbuild/protobuf": "^1.10.1",
"@livekit/protocol": "^1.46.3",
"@livekit/protocol": "^1.46.6",
"camelcase-keys": "^9.0.0",
"jose": "^5.1.2"
},
Expand Down
82 changes: 82 additions & 0 deletions packages/livekit-server-sdk/src/AgentDispatchClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: 2024 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, it } from 'vitest';
import { validateAgentDeploymentString } from './AgentDispatchClient.js';

describe('validateAgentDeploymentString', () => {
it('accepts a valid deployment string with allowed separators', () => {
expect(() => validateAgentDeploymentString('my-agent_v1.0')).not.toThrow();
});

it('accepts a single alphanumeric character', () => {
expect(() => validateAgentDeploymentString('a')).not.toThrow();
});

it('throws when the string exceeds 63 characters', () => {
expect(() => validateAgentDeploymentString('a'.repeat(64))).toThrow(
'Deployment string must not exceed 63 characters',
);
});

it('throws when it does not start with an alphanumeric character', () => {
expect(() => validateAgentDeploymentString('-agent')).toThrow(
'Deployment must start and end with an alphanumeric character',
);
});

it('throws when it does not end with an alphanumeric character', () => {
expect(() => validateAgentDeploymentString('agent.')).toThrow(
'Deployment must start and end with an alphanumeric character',
);
});

it.each(['my agent', 'a/b', 'a@b', 'a:b', 'a+b', 'a,b'])(
'throws on unallowed character in the middle: %j',
(deployment) => {
expect(() => validateAgentDeploymentString(deployment)).toThrow(
'Deployment must start and end with an alphanumeric character',
);
},
);

it('accepts an empty string (targets the production deployment)', () => {
// An empty deployment is explicitly allowed: per the docstring, leaving it
// empty targets the production deployment. The validator early-returns
// before the alphanumeric regex would otherwise reject it.
expect(() => validateAgentDeploymentString('')).not.toThrow();
});

it.each([
['null byte', 'agent\0'],
['tab', 'a\tb'],
['newline in the middle', 'a\nb'],
['carriage return', 'a\rb'],
['vertical tab', 'a\x0bb'],
])('throws on control character (%s)', (_label, deployment) => {
expect(() => validateAgentDeploymentString(deployment)).toThrow(
'Deployment must start and end with an alphanumeric character',
);
});

it('rejects a trailing newline (JS $ must not match before it)', () => {
// Without the `m` flag, JS anchors `$` at the true end of string, so a
// smuggled trailing newline ("agent\n") is correctly rejected rather than
// treated as a valid "agent".
expect(() => validateAgentDeploymentString('agent\n')).toThrow(
'Deployment must start and end with an alphanumeric character',
);
});

it.each([
['path traversal', '../etc'],
['leading slash', '/agent'],
['leading whitespace', ' agent'],
['trailing whitespace', 'agent '],
['unicode homoglyph', 'agént'],
])('rejects security-relevant input (%s)', (_label, deployment) => {
expect(() => validateAgentDeploymentString(deployment)).toThrow(
'Deployment must start and end with an alphanumeric character',
);
});
});
30 changes: 27 additions & 3 deletions packages/livekit-server-sdk/src/AgentDispatchClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,35 @@ import { ServiceBase } from './ServiceBase.js';
import { type Rpc, TwirpRpc, livekitPackage } from './TwirpRPC.js';

interface CreateDispatchOptions {
// any custom data to send along with the job.
// note: this is different from room and participant metadata
/** any custom data to send along with the job.
* note: this is different from room and participant metadata
*/
metadata?: string;
// controls whether the job should be restarted when it fails (cloud only)
/** controls whether the job should be restarted when it fails (cloud only) */
restartPolicy?: JobRestartPolicy;
/** optional deployment to dispatch to. Leave empty to target the production deployment.
* Deployment must start and end with an alphanumeric character and may contain -, _, and . in between.
*/
deployment?: string;
}

const svc = 'AgentDispatchService';

/** @throws TypeError on invalid deployment names */
export function validateAgentDeploymentString(deployment: string): void {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tbd if we even need client side validation. If we don't I'll simply remove this helper and the related tests

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you do decide this is important (you may have more context than me on this), definitely make sure it ends up in livekit/client-sdk-js#1971 too.

I also agree though that I think it probably makes sense to start without this, and if validation is really needed IMO it might be better to do it in upstream systems (ie, something agents hosting related?) and have it surface the validation error over webrtc.

if (deployment.length > 63) {
throw new TypeError('Deployment string must not exceed 63 characters');
}
if (deployment.length === 0) {
return undefined;
}
if (!/^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$/.test(deployment)) {
throw new TypeError(
'Deployment must start and end with an alphanumeric character and may contain -, _, and . in between.',
);
}
}

/**
* Client to access Agent APIs
*/
Expand Down Expand Up @@ -56,11 +76,15 @@ export class AgentDispatchClient extends ServiceBase {
agentName: string,
options?: CreateDispatchOptions,
): Promise<AgentDispatch> {
if (options?.deployment) {
validateAgentDeploymentString(options.deployment);
}
const req = new CreateAgentDispatchRequest({
room: roomName,
agentName,
metadata: options?.metadata,
restartPolicy: options?.restartPolicy,
deployment: options?.deployment,
}).toJson();
const data = await this.rpc.request(
svc,
Expand Down
11 changes: 9 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading