@ct-agents/worker 0.0.1
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/package.json +29 -0
- package/src/index.ts +1134 -0
- package/src/resources/database/index.ts +1 -0
- package/src/resources/database/postgres.ts +138 -0
- package/src/resources/index.ts +2 -0
- package/src/resources/platform-proxy.ts +169 -0
- package/src/sandbox/docker.ts +587 -0
- package/src/sandbox/index.ts +5 -0
- package/src/sandbox/local-process.ts +334 -0
- package/src/sandbox/manager.ts +141 -0
- package/src/sandbox/resources-def.ts +59 -0
- package/src/sandbox/resources.ts +30 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './postgres.js';
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type {
|
|
3
|
+
Database,
|
|
4
|
+
ResourceDefinition,
|
|
5
|
+
} from '@ct-agents/protocol';
|
|
6
|
+
|
|
7
|
+
export type PostgresQueryResult = {
|
|
8
|
+
rows: unknown[];
|
|
9
|
+
rowCount: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type PostgresConnectionExecutor = {
|
|
13
|
+
query(
|
|
14
|
+
sql: string,
|
|
15
|
+
params?: readonly unknown[],
|
|
16
|
+
options?: { readOnly?: boolean; maxRows?: number },
|
|
17
|
+
): Promise<PostgresQueryResult>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type PostgresQueryFunction = (
|
|
21
|
+
sql: string,
|
|
22
|
+
params?: readonly unknown[],
|
|
23
|
+
options?: { readOnly?: boolean; maxRows?: number },
|
|
24
|
+
) => Promise<PostgresQueryResult>;
|
|
25
|
+
|
|
26
|
+
export type PostgresConnection = PostgresConnectionExecutor & {
|
|
27
|
+
withConnection?<T>(callback: (connection: PostgresConnectionExecutor) => Promise<T>): Promise<T>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type PostgresConnectionFactory = (
|
|
31
|
+
connectionString: string,
|
|
32
|
+
options: Pick<PostgresDatabaseOptions, 'allowWrite' | 'maxRows'>,
|
|
33
|
+
) => PostgresConnection;
|
|
34
|
+
|
|
35
|
+
export type PostgresDatabaseOptions = {
|
|
36
|
+
connectionString: string;
|
|
37
|
+
allowWrite?: boolean;
|
|
38
|
+
maxRows?: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type CreatePostgresDatabaseResourceDefinitionInput = {
|
|
42
|
+
createConnection: PostgresConnectionFactory;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const postgresDatabaseOptionsSchema = z.object({
|
|
46
|
+
connectionString: z.string().trim().min(1, 'connectionString 为必填字段'),
|
|
47
|
+
allowWrite: z.boolean().optional(),
|
|
48
|
+
maxRows: z.number().int().positive().optional(),
|
|
49
|
+
}).strict();
|
|
50
|
+
|
|
51
|
+
export function createPostgresDatabaseResourceDefinition(
|
|
52
|
+
input: CreatePostgresDatabaseResourceDefinitionInput,
|
|
53
|
+
): ResourceDefinition<Database, PostgresDatabaseOptions> {
|
|
54
|
+
return {
|
|
55
|
+
id: 'database',
|
|
56
|
+
title: '数据库',
|
|
57
|
+
description: 'worker 侧 PostgreSQL database resource。',
|
|
58
|
+
implementations: [
|
|
59
|
+
{
|
|
60
|
+
id: 'postgres',
|
|
61
|
+
title: 'PostgreSQL',
|
|
62
|
+
description: '通过 worker 本地注入的 PostgreSQL 查询函数访问数据库。',
|
|
63
|
+
optionsSchema: postgresDatabaseOptionsSchema,
|
|
64
|
+
optionsJsonSchema: z.toJSONSchema(postgresDatabaseOptionsSchema),
|
|
65
|
+
factory: (options) => {
|
|
66
|
+
const parsed = postgresDatabaseOptionsSchema.parse(options);
|
|
67
|
+
const connection = input.createConnection(parsed.connectionString, {
|
|
68
|
+
allowWrite: parsed.allowWrite ?? false,
|
|
69
|
+
maxRows: parsed.maxRows,
|
|
70
|
+
});
|
|
71
|
+
if (!connection.withConnection) {
|
|
72
|
+
throw new Error('PostgreSQL database resource 需要提供 withConnection,不能使用无状态 pool.query');
|
|
73
|
+
}
|
|
74
|
+
return createPostgresDatabase({
|
|
75
|
+
...parsed,
|
|
76
|
+
connection,
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function createPostgresDatabase(input: PostgresDatabaseOptions & {
|
|
85
|
+
query?: PostgresQueryFunction;
|
|
86
|
+
withConnection?: PostgresConnection['withConnection'];
|
|
87
|
+
connection?: PostgresConnection;
|
|
88
|
+
}): Database {
|
|
89
|
+
postgresDatabaseOptionsSchema.parse({
|
|
90
|
+
connectionString: input.connectionString,
|
|
91
|
+
allowWrite: input.allowWrite,
|
|
92
|
+
maxRows: input.maxRows,
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
query: async (sql, params, options) => {
|
|
96
|
+
const query = input.connection?.query ?? input.query;
|
|
97
|
+
const withConnection = input.connection?.withConnection ?? input.withConnection;
|
|
98
|
+
if (!query) {
|
|
99
|
+
throw new Error('PostgreSQL database resource 缺少 query 实现');
|
|
100
|
+
}
|
|
101
|
+
if (!options?.readOnly) {
|
|
102
|
+
if (input.allowWrite !== true) {
|
|
103
|
+
throw new Error('当前 database resource 未允许写 SQL');
|
|
104
|
+
}
|
|
105
|
+
return query(sql, params, options);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!withConnection) {
|
|
109
|
+
throw new Error('readOnly 查询需要同连接 withConnection 实现,不能使用无状态 pool.query');
|
|
110
|
+
}
|
|
111
|
+
return withConnection(async (connection) => executeReadOnlyQuery(connection, sql, params, options.maxRows));
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function executeReadOnlyQuery(
|
|
117
|
+
connection: PostgresConnectionExecutor,
|
|
118
|
+
sql: string,
|
|
119
|
+
params: readonly unknown[] | undefined,
|
|
120
|
+
maxRows: number | undefined,
|
|
121
|
+
): Promise<PostgresQueryResult> {
|
|
122
|
+
await connection.query('BEGIN READ ONLY', [], { readOnly: true });
|
|
123
|
+
try {
|
|
124
|
+
const result = await connection.query(wrapReadOnlyQuery(sql, maxRows), params, { readOnly: true, maxRows });
|
|
125
|
+
await connection.query('ROLLBACK', [], { readOnly: true });
|
|
126
|
+
return result;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
await connection.query('ROLLBACK', [], { readOnly: true }).catch(() => undefined);
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function wrapReadOnlyQuery(sql: string, maxRows: number | undefined): string {
|
|
134
|
+
if (maxRows === undefined || !Number.isFinite(maxRows)) {
|
|
135
|
+
return sql;
|
|
136
|
+
}
|
|
137
|
+
return `select * from (${sql}) as ct_agents_limited_query limit ${Math.max(1, Math.trunc(maxRows))}`;
|
|
138
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { Database } from '@ct-agents/protocol';
|
|
2
|
+
|
|
3
|
+
export type PlatformResourceProxyInput = {
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
environmentId: string;
|
|
6
|
+
environmentKey: string;
|
|
7
|
+
slot: string;
|
|
8
|
+
implementationId?: string;
|
|
9
|
+
audit: {
|
|
10
|
+
workId: string;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
toolCallEventId: string;
|
|
13
|
+
};
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
requestTimeoutMs?: number;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
fetchImpl?: typeof fetch;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type PlatformDatabaseProxyResourceInput = Omit<PlatformResourceProxyInput, 'slot'> & {
|
|
21
|
+
slot?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type InvokePlatformResourceProxyInput = PlatformResourceProxyInput & {
|
|
25
|
+
method: string;
|
|
26
|
+
args?: unknown[] | Record<string, unknown>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export class PlatformResourceProxyError extends Error {
|
|
30
|
+
readonly status: number;
|
|
31
|
+
readonly code?: string;
|
|
32
|
+
|
|
33
|
+
constructor(input: { message: string; status: number; code?: string }) {
|
|
34
|
+
super(input.message);
|
|
35
|
+
this.name = 'PlatformResourceProxyError';
|
|
36
|
+
this.status = input.status;
|
|
37
|
+
this.code = input.code;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function trimTrailingSlash(value: string): string {
|
|
42
|
+
return value.endsWith('/') ? value.slice(0, -1) : value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
46
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function expectRecord(value: unknown, message: string): Record<string, unknown> {
|
|
50
|
+
if (!isRecord(value)) {
|
|
51
|
+
throw new Error(message);
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function parseProxyEnvelope(response: Response): Promise<unknown> {
|
|
57
|
+
const body = await response.json().catch(() => null) as unknown;
|
|
58
|
+
const envelope = expectRecord(body, `平台 resource proxy 响应不是 JSON 对象:${response.status}`);
|
|
59
|
+
if (!response.ok || envelope.success !== true) {
|
|
60
|
+
const error = isRecord(envelope.error) ? envelope.error : {};
|
|
61
|
+
throw new PlatformResourceProxyError({
|
|
62
|
+
message: typeof error.message === 'string' ? error.message : `平台 resource proxy 请求失败:${response.status}`,
|
|
63
|
+
status: response.status,
|
|
64
|
+
...(typeof error.code === 'string' ? { code: error.code } : {}),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const data = expectRecord(envelope.data, 'resource proxy 响应缺少 data');
|
|
68
|
+
return data.result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 通过平台 Resource proxy 调用一个 resource method。
|
|
73
|
+
*
|
|
74
|
+
* worker 只持有 environment key,不持有平台内置 resource 的原始密钥。
|
|
75
|
+
*/
|
|
76
|
+
export async function invokePlatformResourceProxy(input: InvokePlatformResourceProxyInput): Promise<unknown> {
|
|
77
|
+
const baseUrl = trimTrailingSlash(input.baseUrl);
|
|
78
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
79
|
+
const headers = new Headers();
|
|
80
|
+
headers.set('x-api-key', input.environmentKey);
|
|
81
|
+
headers.set('content-type', 'application/json');
|
|
82
|
+
const body = {
|
|
83
|
+
...(input.implementationId ? { implementationId: input.implementationId } : {}),
|
|
84
|
+
method: input.method,
|
|
85
|
+
...(input.audit ? { audit: input.audit } : {}),
|
|
86
|
+
args: input.args ?? [],
|
|
87
|
+
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
|
|
88
|
+
};
|
|
89
|
+
const controller = new AbortController();
|
|
90
|
+
const abortHandler = () => controller.abort();
|
|
91
|
+
input.signal?.addEventListener('abort', abortHandler, { once: true });
|
|
92
|
+
const requestTimeoutMs = input.requestTimeoutMs ?? (input.timeoutMs ? input.timeoutMs + 5_000 : 35_000);
|
|
93
|
+
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
|
|
94
|
+
try {
|
|
95
|
+
const response = await fetchImpl(`${baseUrl}/v1/environments/${encodeURIComponent(input.environmentId)}/resources/${encodeURIComponent(input.slot)}/invoke`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers,
|
|
98
|
+
body: JSON.stringify(body),
|
|
99
|
+
signal: controller.signal,
|
|
100
|
+
});
|
|
101
|
+
return await parseProxyEnvelope(response);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (isAbortError(error)) {
|
|
104
|
+
throw new Error('平台 resource proxy 请求超时');
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
} finally {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
input.signal?.removeEventListener('abort', abortHandler);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 创建一个通过平台 Resource proxy 访问的动态 resource。
|
|
115
|
+
*
|
|
116
|
+
* 该代理不暴露给业务仓;真实 method 是否存在由平台 route 在运行时校验。
|
|
117
|
+
*/
|
|
118
|
+
export function createPlatformResourceProxy<TResource extends object>(
|
|
119
|
+
input: PlatformResourceProxyInput,
|
|
120
|
+
): TResource {
|
|
121
|
+
const methods = new Map<string, (...args: unknown[]) => Promise<unknown>>();
|
|
122
|
+
return new Proxy({}, {
|
|
123
|
+
get(_target, property) {
|
|
124
|
+
if (typeof property !== 'string' || property === 'then' || property === 'dispose') {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
let method = methods.get(property);
|
|
128
|
+
if (!method) {
|
|
129
|
+
method = async (...args: unknown[]) => invokePlatformResourceProxy({
|
|
130
|
+
...input,
|
|
131
|
+
method: property,
|
|
132
|
+
args,
|
|
133
|
+
});
|
|
134
|
+
methods.set(property, method);
|
|
135
|
+
}
|
|
136
|
+
return method;
|
|
137
|
+
},
|
|
138
|
+
}) as TResource;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 创建通过平台 Resource proxy 访问的 database resource。
|
|
143
|
+
*
|
|
144
|
+
* worker 只持有 environment key,不持有平台内置 resource 的原始密钥。
|
|
145
|
+
*/
|
|
146
|
+
export function createPlatformDatabaseProxyResource(input: PlatformDatabaseProxyResourceInput): Database {
|
|
147
|
+
const slot = input.slot?.trim() || 'database';
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
async query(sql, params, options) {
|
|
151
|
+
const timeoutMs = options?.timeoutMs ?? input.timeoutMs;
|
|
152
|
+
return invokePlatformResourceProxy({
|
|
153
|
+
...input,
|
|
154
|
+
slot,
|
|
155
|
+
method: 'query',
|
|
156
|
+
args: {
|
|
157
|
+
sql,
|
|
158
|
+
...(params !== undefined ? { params: Array.from(params) } : {}),
|
|
159
|
+
...(options !== undefined ? { options } : {}),
|
|
160
|
+
},
|
|
161
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isAbortError(error: unknown) {
|
|
168
|
+
return error instanceof DOMException && error.name === 'AbortError';
|
|
169
|
+
}
|