@tjamescouch/agentchat 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +12 -0
- package/.github/workflows/fly-deploy.yml +18 -0
- package/Dockerfile +12 -0
- package/README.md +296 -0
- package/ROADMAP.md +88 -0
- package/SPEC.md +279 -0
- package/bin/agentchat.js +702 -0
- package/fly.toml +21 -0
- package/lib/client.js +362 -0
- package/lib/deploy/akash.js +811 -0
- package/lib/deploy/config.js +128 -0
- package/lib/deploy/index.js +149 -0
- package/lib/identity.js +166 -0
- package/lib/protocol.js +236 -0
- package/lib/server.js +526 -0
- package/package.json +44 -0
- package/quick-test.sh +45 -0
- package/test/integration.test.js +536 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentChat Deploy Configuration
|
|
3
|
+
* Parser for deploy.yaml configuration files
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'fs/promises';
|
|
7
|
+
import yaml from 'js-yaml';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Default configuration values
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_CONFIG = {
|
|
13
|
+
provider: 'docker',
|
|
14
|
+
port: 6667,
|
|
15
|
+
host: '0.0.0.0',
|
|
16
|
+
name: 'agentchat',
|
|
17
|
+
logMessages: false,
|
|
18
|
+
volumes: false,
|
|
19
|
+
healthCheck: true,
|
|
20
|
+
tls: null,
|
|
21
|
+
network: null
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Load and parse deploy.yaml configuration
|
|
26
|
+
* @param {string} configPath - Path to configuration file
|
|
27
|
+
* @returns {Promise<object>} Validated configuration object
|
|
28
|
+
*/
|
|
29
|
+
export async function loadConfig(configPath) {
|
|
30
|
+
const content = await fs.readFile(configPath, 'utf-8');
|
|
31
|
+
const parsed = yaml.load(content);
|
|
32
|
+
return validateConfig(parsed);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate configuration object
|
|
37
|
+
* @param {object} config - Raw configuration object
|
|
38
|
+
* @returns {object} Validated and merged configuration
|
|
39
|
+
* @throws {Error} If configuration is invalid
|
|
40
|
+
*/
|
|
41
|
+
export function validateConfig(config) {
|
|
42
|
+
if (!config || typeof config !== 'object') {
|
|
43
|
+
throw new Error('Configuration must be an object');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const result = { ...DEFAULT_CONFIG, ...config };
|
|
47
|
+
|
|
48
|
+
// Validate provider
|
|
49
|
+
if (!['docker', 'akash'].includes(result.provider)) {
|
|
50
|
+
throw new Error(`Invalid provider: ${result.provider}. Must be 'docker' or 'akash'`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Validate port
|
|
54
|
+
const port = parseInt(result.port);
|
|
55
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
56
|
+
throw new Error(`Invalid port: ${result.port}. Must be between 1 and 65535`);
|
|
57
|
+
}
|
|
58
|
+
result.port = port;
|
|
59
|
+
|
|
60
|
+
// Validate host
|
|
61
|
+
if (typeof result.host !== 'string' || result.host.length === 0) {
|
|
62
|
+
throw new Error('Invalid host: must be a non-empty string');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Validate name
|
|
66
|
+
if (typeof result.name !== 'string' || result.name.length === 0) {
|
|
67
|
+
throw new Error('Invalid name: must be a non-empty string');
|
|
68
|
+
}
|
|
69
|
+
// Docker container name must be alphanumeric with dashes/underscores
|
|
70
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(result.name)) {
|
|
71
|
+
throw new Error('Invalid name: must start with alphanumeric and contain only alphanumeric, dash, underscore');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Validate TLS config
|
|
75
|
+
if (result.tls) {
|
|
76
|
+
if (typeof result.tls !== 'object') {
|
|
77
|
+
throw new Error('TLS config must be an object with cert and key paths');
|
|
78
|
+
}
|
|
79
|
+
if (!result.tls.cert || typeof result.tls.cert !== 'string') {
|
|
80
|
+
throw new Error('TLS config must include cert path');
|
|
81
|
+
}
|
|
82
|
+
if (!result.tls.key || typeof result.tls.key !== 'string') {
|
|
83
|
+
throw new Error('TLS config must include key path');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Validate network
|
|
88
|
+
if (result.network !== null && typeof result.network !== 'string') {
|
|
89
|
+
throw new Error('Network must be a string or null');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Ensure booleans
|
|
93
|
+
result.logMessages = Boolean(result.logMessages);
|
|
94
|
+
result.volumes = Boolean(result.volumes);
|
|
95
|
+
result.healthCheck = result.healthCheck !== false;
|
|
96
|
+
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Generate example deploy.yaml content
|
|
102
|
+
* @returns {string} Example YAML configuration
|
|
103
|
+
*/
|
|
104
|
+
export function generateExampleConfig() {
|
|
105
|
+
return `# AgentChat deployment configuration
|
|
106
|
+
provider: docker
|
|
107
|
+
port: 6667
|
|
108
|
+
host: 0.0.0.0
|
|
109
|
+
name: agentchat
|
|
110
|
+
|
|
111
|
+
# Enable data persistence volumes
|
|
112
|
+
volumes: false
|
|
113
|
+
|
|
114
|
+
# Health check (default: true)
|
|
115
|
+
healthCheck: true
|
|
116
|
+
|
|
117
|
+
# Logging (default: false)
|
|
118
|
+
logMessages: false
|
|
119
|
+
|
|
120
|
+
# TLS configuration (optional)
|
|
121
|
+
# tls:
|
|
122
|
+
# cert: ./certs/cert.pem
|
|
123
|
+
# key: ./certs/key.pem
|
|
124
|
+
|
|
125
|
+
# Docker network (optional)
|
|
126
|
+
# network: agentchat-net
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentChat Deployment Module
|
|
3
|
+
* Generate deployment files for agentchat servers
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import yaml from 'js-yaml';
|
|
7
|
+
|
|
8
|
+
// Re-export Akash module
|
|
9
|
+
export {
|
|
10
|
+
AkashWallet,
|
|
11
|
+
AkashClient,
|
|
12
|
+
generateSDL as generateAkashSDL,
|
|
13
|
+
generateWallet,
|
|
14
|
+
checkBalance,
|
|
15
|
+
createDeployment,
|
|
16
|
+
listDeployments,
|
|
17
|
+
closeDeployment,
|
|
18
|
+
queryBids,
|
|
19
|
+
acceptBid,
|
|
20
|
+
getDeploymentStatus,
|
|
21
|
+
NETWORKS as AKASH_NETWORKS,
|
|
22
|
+
WALLET_PATH as AKASH_WALLET_PATH
|
|
23
|
+
} from './akash.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Generate docker-compose.yml for self-hosting
|
|
27
|
+
* @param {object} options - Configuration options
|
|
28
|
+
* @returns {string} docker-compose.yml content
|
|
29
|
+
*/
|
|
30
|
+
export async function deployToDocker(options = {}) {
|
|
31
|
+
const config = {
|
|
32
|
+
port: options.port || 6667,
|
|
33
|
+
host: options.host || '0.0.0.0',
|
|
34
|
+
name: options.name || 'agentchat',
|
|
35
|
+
logMessages: options.logMessages || false,
|
|
36
|
+
volumes: options.volumes || false,
|
|
37
|
+
tls: options.tls || null,
|
|
38
|
+
network: options.network || null,
|
|
39
|
+
healthCheck: options.healthCheck !== false
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Build compose object
|
|
43
|
+
const compose = {
|
|
44
|
+
version: '3.8',
|
|
45
|
+
services: {
|
|
46
|
+
agentchat: {
|
|
47
|
+
image: 'agentchat:latest',
|
|
48
|
+
build: '.',
|
|
49
|
+
container_name: config.name,
|
|
50
|
+
ports: [`${config.port}:6667`],
|
|
51
|
+
environment: [
|
|
52
|
+
`PORT=6667`,
|
|
53
|
+
`HOST=${config.host}`,
|
|
54
|
+
`SERVER_NAME=${config.name}`,
|
|
55
|
+
`LOG_MESSAGES=${config.logMessages}`
|
|
56
|
+
],
|
|
57
|
+
restart: 'unless-stopped'
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const service = compose.services.agentchat;
|
|
63
|
+
|
|
64
|
+
// Add health check
|
|
65
|
+
if (config.healthCheck) {
|
|
66
|
+
service.healthcheck = {
|
|
67
|
+
test: ['CMD', 'node', '-e',
|
|
68
|
+
"const ws = new (require('ws'))('ws://localhost:6667'); ws.on('open', () => process.exit(0)); ws.on('error', () => process.exit(1)); setTimeout(() => process.exit(1), 5000);"
|
|
69
|
+
],
|
|
70
|
+
interval: '30s',
|
|
71
|
+
timeout: '10s',
|
|
72
|
+
retries: 3,
|
|
73
|
+
start_period: '10s'
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Add volumes if enabled
|
|
78
|
+
if (config.volumes) {
|
|
79
|
+
service.volumes = service.volumes || [];
|
|
80
|
+
service.volumes.push('agentchat-data:/app/data');
|
|
81
|
+
compose.volumes = { 'agentchat-data': {} };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Add TLS certificate mounts
|
|
85
|
+
if (config.tls) {
|
|
86
|
+
service.volumes = service.volumes || [];
|
|
87
|
+
service.volumes.push(`${config.tls.cert}:/app/certs/cert.pem:ro`);
|
|
88
|
+
service.volumes.push(`${config.tls.key}:/app/certs/key.pem:ro`);
|
|
89
|
+
service.environment.push('TLS_CERT=/app/certs/cert.pem');
|
|
90
|
+
service.environment.push('TLS_KEY=/app/certs/key.pem');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Add network configuration
|
|
94
|
+
if (config.network) {
|
|
95
|
+
service.networks = [config.network];
|
|
96
|
+
compose.networks = {
|
|
97
|
+
[config.network]: {
|
|
98
|
+
driver: 'bridge'
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return yaml.dump(compose, {
|
|
104
|
+
lineWidth: -1,
|
|
105
|
+
noRefs: true,
|
|
106
|
+
quotingType: '"'
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Generate Dockerfile for agentchat server
|
|
112
|
+
* @param {object} options - Configuration options
|
|
113
|
+
* @returns {string} Dockerfile content
|
|
114
|
+
*/
|
|
115
|
+
export async function generateDockerfile(options = {}) {
|
|
116
|
+
const tls = options.tls || false;
|
|
117
|
+
|
|
118
|
+
return `FROM node:18-alpine
|
|
119
|
+
|
|
120
|
+
WORKDIR /app
|
|
121
|
+
|
|
122
|
+
# Install dependencies first for better layer caching
|
|
123
|
+
COPY package*.json ./
|
|
124
|
+
RUN npm ci --production
|
|
125
|
+
|
|
126
|
+
# Copy application code
|
|
127
|
+
COPY . .
|
|
128
|
+
|
|
129
|
+
# Create data directory for persistence
|
|
130
|
+
RUN mkdir -p /app/data
|
|
131
|
+
|
|
132
|
+
# Default environment variables
|
|
133
|
+
ENV PORT=6667
|
|
134
|
+
ENV HOST=0.0.0.0
|
|
135
|
+
ENV SERVER_NAME=agentchat
|
|
136
|
+
ENV LOG_MESSAGES=false
|
|
137
|
+
${tls ? `ENV TLS_CERT=""
|
|
138
|
+
ENV TLS_KEY=""
|
|
139
|
+
` : ''}
|
|
140
|
+
EXPOSE 6667
|
|
141
|
+
|
|
142
|
+
# Health check
|
|
143
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \\
|
|
144
|
+
CMD node -e "const ws = new (require('ws'))('ws://localhost:' + (process.env.PORT || 6667)); ws.on('open', () => process.exit(0)); ws.on('error', () => process.exit(1)); setTimeout(() => process.exit(1), 5000);"
|
|
145
|
+
|
|
146
|
+
# Start server
|
|
147
|
+
CMD ["node", "bin/agentchat.js", "serve"]
|
|
148
|
+
`;
|
|
149
|
+
}
|
package/lib/identity.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentChat Identity Module
|
|
3
|
+
* Ed25519 key generation, storage, and signing
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
import fs from 'fs/promises';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
|
|
11
|
+
// Default identity file location
|
|
12
|
+
export const DEFAULT_IDENTITY_PATH = path.join(os.homedir(), '.agentchat', 'identity.json');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generate stable agent ID from pubkey
|
|
16
|
+
* Returns first 8 chars of SHA256 hash (hex)
|
|
17
|
+
*/
|
|
18
|
+
export function pubkeyToAgentId(pubkey) {
|
|
19
|
+
const hash = crypto.createHash('sha256').update(pubkey).digest('hex');
|
|
20
|
+
return hash.substring(0, 8);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Validate Ed25519 public key in PEM format
|
|
25
|
+
*/
|
|
26
|
+
export function isValidPubkey(pubkey) {
|
|
27
|
+
if (!pubkey || typeof pubkey !== 'string') return false;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const keyObj = crypto.createPublicKey(pubkey);
|
|
31
|
+
return keyObj.asymmetricKeyType === 'ed25519';
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* AgentChat Identity
|
|
39
|
+
* Represents an agent's Ed25519 keypair and associated metadata
|
|
40
|
+
*/
|
|
41
|
+
export class Identity {
|
|
42
|
+
constructor(data) {
|
|
43
|
+
this.name = data.name;
|
|
44
|
+
this.pubkey = data.pubkey; // PEM format
|
|
45
|
+
this.privkey = data.privkey; // PEM format (null if loaded from export)
|
|
46
|
+
this.created = data.created;
|
|
47
|
+
|
|
48
|
+
// Lazy-load crypto key objects
|
|
49
|
+
this._publicKey = null;
|
|
50
|
+
this._privateKey = null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Generate new Ed25519 keypair
|
|
55
|
+
*/
|
|
56
|
+
static generate(name) {
|
|
57
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
58
|
+
|
|
59
|
+
return new Identity({
|
|
60
|
+
name,
|
|
61
|
+
pubkey: publicKey.export({ type: 'spki', format: 'pem' }),
|
|
62
|
+
privkey: privateKey.export({ type: 'pkcs8', format: 'pem' }),
|
|
63
|
+
created: new Date().toISOString()
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Load identity from JSON file
|
|
69
|
+
*/
|
|
70
|
+
static async load(filePath = DEFAULT_IDENTITY_PATH) {
|
|
71
|
+
const data = await fs.readFile(filePath, 'utf-8');
|
|
72
|
+
const parsed = JSON.parse(data);
|
|
73
|
+
return new Identity(parsed);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Save identity to JSON file
|
|
78
|
+
*/
|
|
79
|
+
async save(filePath = DEFAULT_IDENTITY_PATH) {
|
|
80
|
+
// Ensure directory exists
|
|
81
|
+
const dir = path.dirname(filePath);
|
|
82
|
+
await fs.mkdir(dir, { recursive: true });
|
|
83
|
+
|
|
84
|
+
const data = {
|
|
85
|
+
name: this.name,
|
|
86
|
+
pubkey: this.pubkey,
|
|
87
|
+
privkey: this.privkey,
|
|
88
|
+
created: this.created
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
await fs.writeFile(filePath, JSON.stringify(data, null, 2), {
|
|
92
|
+
mode: 0o600 // Owner read/write only
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Check if identity file exists
|
|
98
|
+
*/
|
|
99
|
+
static async exists(filePath = DEFAULT_IDENTITY_PATH) {
|
|
100
|
+
try {
|
|
101
|
+
await fs.access(filePath);
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get fingerprint (first 16 chars of SHA256 hash of pubkey)
|
|
110
|
+
*/
|
|
111
|
+
getFingerprint() {
|
|
112
|
+
const hash = crypto.createHash('sha256').update(this.pubkey).digest('hex');
|
|
113
|
+
return hash.substring(0, 16);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get stable agent ID (first 8 chars of fingerprint)
|
|
118
|
+
*/
|
|
119
|
+
getAgentId() {
|
|
120
|
+
return pubkeyToAgentId(this.pubkey);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Sign data with private key
|
|
125
|
+
* Returns base64-encoded signature
|
|
126
|
+
*/
|
|
127
|
+
sign(data) {
|
|
128
|
+
if (!this.privkey) {
|
|
129
|
+
throw new Error('Private key not available (identity was loaded from export)');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!this._privateKey) {
|
|
133
|
+
this._privateKey = crypto.createPrivateKey(this.privkey);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
137
|
+
const signature = crypto.sign(null, buffer, this._privateKey);
|
|
138
|
+
return signature.toString('base64');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Verify a signature
|
|
143
|
+
* Static method for verifying any message
|
|
144
|
+
*/
|
|
145
|
+
static verify(data, signature, pubkey) {
|
|
146
|
+
try {
|
|
147
|
+
const keyObj = crypto.createPublicKey(pubkey);
|
|
148
|
+
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
149
|
+
const sigBuffer = Buffer.from(signature, 'base64');
|
|
150
|
+
return crypto.verify(null, buffer, keyObj, sigBuffer);
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Export for sharing (pubkey only, no private key)
|
|
158
|
+
*/
|
|
159
|
+
export() {
|
|
160
|
+
return {
|
|
161
|
+
name: this.name,
|
|
162
|
+
pubkey: this.pubkey,
|
|
163
|
+
created: this.created
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
package/lib/protocol.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentChat Protocol
|
|
3
|
+
* Message types and validation for agent-to-agent communication
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
|
|
8
|
+
// Client -> Server message types
|
|
9
|
+
export const ClientMessageType = {
|
|
10
|
+
IDENTIFY: 'IDENTIFY',
|
|
11
|
+
JOIN: 'JOIN',
|
|
12
|
+
LEAVE: 'LEAVE',
|
|
13
|
+
MSG: 'MSG',
|
|
14
|
+
LIST_CHANNELS: 'LIST_CHANNELS',
|
|
15
|
+
LIST_AGENTS: 'LIST_AGENTS',
|
|
16
|
+
CREATE_CHANNEL: 'CREATE_CHANNEL',
|
|
17
|
+
INVITE: 'INVITE',
|
|
18
|
+
PING: 'PING'
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Server -> Client message types
|
|
22
|
+
export const ServerMessageType = {
|
|
23
|
+
WELCOME: 'WELCOME',
|
|
24
|
+
MSG: 'MSG',
|
|
25
|
+
JOINED: 'JOINED',
|
|
26
|
+
LEFT: 'LEFT',
|
|
27
|
+
AGENT_JOINED: 'AGENT_JOINED',
|
|
28
|
+
AGENT_LEFT: 'AGENT_LEFT',
|
|
29
|
+
CHANNELS: 'CHANNELS',
|
|
30
|
+
AGENTS: 'AGENTS',
|
|
31
|
+
ERROR: 'ERROR',
|
|
32
|
+
PONG: 'PONG'
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Error codes
|
|
36
|
+
export const ErrorCode = {
|
|
37
|
+
AUTH_REQUIRED: 'AUTH_REQUIRED',
|
|
38
|
+
CHANNEL_NOT_FOUND: 'CHANNEL_NOT_FOUND',
|
|
39
|
+
NOT_INVITED: 'NOT_INVITED',
|
|
40
|
+
INVALID_MSG: 'INVALID_MSG',
|
|
41
|
+
RATE_LIMITED: 'RATE_LIMITED',
|
|
42
|
+
AGENT_NOT_FOUND: 'AGENT_NOT_FOUND',
|
|
43
|
+
CHANNEL_EXISTS: 'CHANNEL_EXISTS',
|
|
44
|
+
INVALID_NAME: 'INVALID_NAME'
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if a target is a channel (#name) or agent (@name)
|
|
49
|
+
*/
|
|
50
|
+
export function isChannel(target) {
|
|
51
|
+
return target && target.startsWith('#');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isAgent(target) {
|
|
55
|
+
return target && target.startsWith('@');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validate agent name
|
|
60
|
+
* - 1-32 characters
|
|
61
|
+
* - alphanumeric, dash, underscore
|
|
62
|
+
* - no spaces
|
|
63
|
+
*/
|
|
64
|
+
export function isValidName(name) {
|
|
65
|
+
if (!name || typeof name !== 'string') return false;
|
|
66
|
+
if (name.length < 1 || name.length > 32) return false;
|
|
67
|
+
return /^[a-zA-Z0-9_-]+$/.test(name);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Validate channel name
|
|
72
|
+
* - starts with #
|
|
73
|
+
* - 2-32 characters total
|
|
74
|
+
* - alphanumeric, dash, underscore after #
|
|
75
|
+
*/
|
|
76
|
+
export function isValidChannel(channel) {
|
|
77
|
+
if (!channel || typeof channel !== 'string') return false;
|
|
78
|
+
if (!channel.startsWith('#')) return false;
|
|
79
|
+
const name = channel.slice(1);
|
|
80
|
+
if (name.length < 1 || name.length > 31) return false;
|
|
81
|
+
return /^[a-zA-Z0-9_-]+$/.test(name);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validate Ed25519 public key in PEM format
|
|
86
|
+
*/
|
|
87
|
+
export function isValidPubkey(pubkey) {
|
|
88
|
+
if (!pubkey || typeof pubkey !== 'string') return false;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const keyObj = crypto.createPublicKey(pubkey);
|
|
92
|
+
return keyObj.asymmetricKeyType === 'ed25519';
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Generate stable agent ID from pubkey
|
|
100
|
+
* Returns first 8 chars of SHA256 hash (hex)
|
|
101
|
+
*/
|
|
102
|
+
export function pubkeyToAgentId(pubkey) {
|
|
103
|
+
const hash = crypto.createHash('sha256').update(pubkey).digest('hex');
|
|
104
|
+
return hash.substring(0, 8);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Create a message object with timestamp
|
|
109
|
+
*/
|
|
110
|
+
export function createMessage(type, data = {}) {
|
|
111
|
+
return {
|
|
112
|
+
type,
|
|
113
|
+
ts: Date.now(),
|
|
114
|
+
...data
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Create an error message
|
|
120
|
+
*/
|
|
121
|
+
export function createError(code, message) {
|
|
122
|
+
return createMessage(ServerMessageType.ERROR, { code, message });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Validate incoming client message
|
|
127
|
+
* Returns { valid: true, msg } or { valid: false, error }
|
|
128
|
+
*/
|
|
129
|
+
export function validateClientMessage(raw) {
|
|
130
|
+
let msg;
|
|
131
|
+
|
|
132
|
+
// Parse JSON
|
|
133
|
+
try {
|
|
134
|
+
msg = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
135
|
+
} catch (e) {
|
|
136
|
+
return { valid: false, error: 'Invalid JSON' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Must have type
|
|
140
|
+
if (!msg.type) {
|
|
141
|
+
return { valid: false, error: 'Missing message type' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Validate by type
|
|
145
|
+
switch (msg.type) {
|
|
146
|
+
case ClientMessageType.IDENTIFY:
|
|
147
|
+
if (!isValidName(msg.name)) {
|
|
148
|
+
return { valid: false, error: 'Invalid agent name' };
|
|
149
|
+
}
|
|
150
|
+
// Validate pubkey if provided
|
|
151
|
+
if (msg.pubkey !== undefined && msg.pubkey !== null) {
|
|
152
|
+
if (!isValidPubkey(msg.pubkey)) {
|
|
153
|
+
return { valid: false, error: 'Invalid public key format (must be Ed25519 PEM)' };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
|
|
158
|
+
case ClientMessageType.JOIN:
|
|
159
|
+
case ClientMessageType.LEAVE:
|
|
160
|
+
case ClientMessageType.LIST_AGENTS:
|
|
161
|
+
if (!isValidChannel(msg.channel)) {
|
|
162
|
+
return { valid: false, error: 'Invalid channel name' };
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
|
|
166
|
+
case ClientMessageType.MSG:
|
|
167
|
+
if (!msg.to) {
|
|
168
|
+
return { valid: false, error: 'Missing target' };
|
|
169
|
+
}
|
|
170
|
+
if (!isChannel(msg.to) && !isAgent(msg.to)) {
|
|
171
|
+
return { valid: false, error: 'Invalid target (must start with # or @)' };
|
|
172
|
+
}
|
|
173
|
+
if (typeof msg.content !== 'string') {
|
|
174
|
+
return { valid: false, error: 'Missing or invalid content' };
|
|
175
|
+
}
|
|
176
|
+
if (msg.content.length > 4096) {
|
|
177
|
+
return { valid: false, error: 'Content too long (max 4096 chars)' };
|
|
178
|
+
}
|
|
179
|
+
// Validate signature format if present
|
|
180
|
+
if (msg.sig !== undefined && typeof msg.sig !== 'string') {
|
|
181
|
+
return { valid: false, error: 'Invalid signature format' };
|
|
182
|
+
}
|
|
183
|
+
break;
|
|
184
|
+
|
|
185
|
+
case ClientMessageType.CREATE_CHANNEL:
|
|
186
|
+
if (!isValidChannel(msg.channel)) {
|
|
187
|
+
return { valid: false, error: 'Invalid channel name' };
|
|
188
|
+
}
|
|
189
|
+
break;
|
|
190
|
+
|
|
191
|
+
case ClientMessageType.INVITE:
|
|
192
|
+
if (!isValidChannel(msg.channel)) {
|
|
193
|
+
return { valid: false, error: 'Invalid channel name' };
|
|
194
|
+
}
|
|
195
|
+
if (!msg.agent || !isAgent(msg.agent)) {
|
|
196
|
+
return { valid: false, error: 'Invalid agent target' };
|
|
197
|
+
}
|
|
198
|
+
break;
|
|
199
|
+
|
|
200
|
+
case ClientMessageType.LIST_CHANNELS:
|
|
201
|
+
case ClientMessageType.PING:
|
|
202
|
+
// No additional validation needed
|
|
203
|
+
break;
|
|
204
|
+
|
|
205
|
+
default:
|
|
206
|
+
return { valid: false, error: `Unknown message type: ${msg.type}` };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { valid: true, msg };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Generate a unique agent ID
|
|
214
|
+
*/
|
|
215
|
+
export function generateAgentId() {
|
|
216
|
+
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
217
|
+
let id = '';
|
|
218
|
+
for (let i = 0; i < 8; i++) {
|
|
219
|
+
id += chars[Math.floor(Math.random() * chars.length)];
|
|
220
|
+
}
|
|
221
|
+
return id;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Serialize message for sending over WebSocket
|
|
226
|
+
*/
|
|
227
|
+
export function serialize(msg) {
|
|
228
|
+
return JSON.stringify(msg);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Parse message from WebSocket
|
|
233
|
+
*/
|
|
234
|
+
export function parse(data) {
|
|
235
|
+
return JSON.parse(data);
|
|
236
|
+
}
|