Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
27 changes: 27 additions & 0 deletions pnpm-lock.yaml

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

55 changes: 55 additions & 0 deletions tegg/core/agent-runtime/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"name": "@eggjs/agent-runtime",
"version": "4.0.2-beta.1",
"description": "Smart default runtime for @AgentController in tegg",
"keywords": [
"agent",
"egg",
"tegg",
"typescript"
],
"homepage": "https://github.com/eggjs/egg/tree/next/tegg/core/agent-runtime",
"bugs": {
"url": "https://github.com/eggjs/egg/issues"
},
"license": "MIT",
"author": "killagu <killa123@126.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/eggjs/egg.git",
"directory": "tegg/core/agent-runtime"
},
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./src/index.ts",
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public",
"exports": {
".": "./dist/index.js",
"./package.json": "./package.json"
}
},
"scripts": {
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@eggjs/tegg-runtime": "workspace:*",
"@eggjs/tegg-types": "workspace:*"
},
"devDependencies": {
"@eggjs/controller-decorator": "workspace:*",
"@types/node": "catalog:",
"typescript": "catalog:"
},
"engines": {
"node": ">=22.18.0"
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.

critical

The specified Node.js engine version >=22.18.0 appears to be a typo, as this version has not been released. This will prevent users from installing the package. Please use a valid version range, such as >=22.0.0.

Suggested change
"node": ">=22.18.0"
"node": ">=22.0.0"

}
}
43 changes: 43 additions & 0 deletions tegg/core/agent-runtime/src/AgentStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { InputMessage, MessageObject, AgentRunConfig, RunStatus } from '@eggjs/controller-decorator';

export interface ThreadRecord {
id: string;
object: 'thread';
messages: MessageObject[];
metadata: Record<string, unknown>;
created_at: number; // Unix seconds
}

export interface RunRecord {
id: string;
object: 'thread.run';
thread_id?: string;
status: RunStatus;
input: InputMessage[];
output?: MessageObject[];
last_error?: { code: string; message: string } | null;
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | null;
config?: AgentRunConfig;
metadata?: Record<string, unknown>;
created_at: number;
started_at?: number | null;
completed_at?: number | null;
cancelled_at?: number | null;
failed_at?: number | null;
}

export interface AgentStore {
init?(): Promise<void>;
destroy?(): Promise<void>;
createThread(metadata?: Record<string, unknown>): Promise<ThreadRecord>;
getThread(threadId: string): Promise<ThreadRecord>;
appendMessages(threadId: string, messages: MessageObject[]): Promise<void>;
createRun(
input: InputMessage[],
threadId?: string,
config?: AgentRunConfig,
metadata?: Record<string, unknown>,
): Promise<RunRecord>;
getRun(runId: string): Promise<RunRecord>;
updateRun(runId: string, updates: Partial<RunRecord>): Promise<void>;
}
123 changes: 123 additions & 0 deletions tegg/core/agent-runtime/src/FileAgentStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';

import type { InputMessage, MessageObject, AgentRunConfig } from '@eggjs/controller-decorator';

import type { AgentStore, ThreadRecord, RunRecord } from './AgentStore.ts';

export interface FileAgentStoreOptions {
dataDir: string;
}

export class FileAgentStore implements AgentStore {
private readonly dataDir: string;
private readonly threadsDir: string;
private readonly runsDir: string;

constructor(options: FileAgentStoreOptions) {
this.dataDir = options.dataDir;
this.threadsDir = path.join(this.dataDir, 'threads');
this.runsDir = path.join(this.dataDir, 'runs');
}

private safePath(baseDir: string, id: string): string {
if (!id) {
throw new Error('Invalid id: id must not be empty');
}
const filePath = path.join(baseDir, `${id}.json`);
if (!filePath.startsWith(baseDir + path.sep)) {
throw new Error(`Invalid id: ${id}`);
}
return filePath;
}
Comment on lines +27 to +36
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.

⚠️ Potential issue | 🟡 Minor

Path traversal check may be insufficient for edge cases.

The safePath check could be bypassed in edge cases. For example, if id contains path separators that get normalized differently, or if baseDir doesn't end with a separator. Consider validating the ID format directly.

🛡️ Proposed enhanced validation
   private safePath(baseDir: string, id: string): string {
     if (!id) {
       throw new Error('Invalid id: id must not be empty');
     }
+    // Reject IDs containing path separators or traversal sequences
+    if (/[/\\]|\.\./.test(id)) {
+      throw new Error(`Invalid id: ${id}`);
+    }
     const filePath = path.join(baseDir, `${id}.json`);
-    if (!filePath.startsWith(baseDir + path.sep)) {
+    // Resolve to absolute paths for reliable comparison
+    const resolvedPath = path.resolve(filePath);
+    const resolvedBase = path.resolve(baseDir);
+    if (!resolvedPath.startsWith(resolvedBase + path.sep)) {
       throw new Error(`Invalid id: ${id}`);
     }
     return filePath;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tegg/core/agent-runtime/src/FileAgentStore.ts` around lines 24 - 33, The
current safePath method can be bypassed by crafted ids; change it to resolve and
validate paths and restrict id characters: resolve baseDir with path.resolve,
ensure the resulting file path is path.resolve(baseDir, `${id}.json`), and check
that the resolved file path starts with resolvedBaseDir + path.sep; additionally
enforce a strict ID format (e.g., allow only alphanumerics, dash, underscore)
and reject any id containing path separators or null bytes before constructing
the path to prevent traversal (update safePath to perform these checks and throw
on invalid ids).


async init(): Promise<void> {
await fs.mkdir(this.threadsDir, { recursive: true });
await fs.mkdir(this.runsDir, { recursive: true });
}

async createThread(metadata?: Record<string, unknown>): Promise<ThreadRecord> {
const threadId = `thread_${crypto.randomUUID()}`;
const record: ThreadRecord = {
id: threadId,
object: 'thread',
messages: [],
metadata: metadata ?? {},
created_at: Math.floor(Date.now() / 1000),
};
await this.writeFile(this.safePath(this.threadsDir, threadId), record);
return record;
}

async getThread(threadId: string): Promise<ThreadRecord> {
const filePath = this.safePath(this.threadsDir, threadId);
const data = await this.readFile(filePath);
if (!data) {
throw new Error(`Thread ${threadId} not found`);
}
return data as ThreadRecord;
}

// Note: read-modify-write without locking. Concurrent appends to the same thread may lose messages.
// This is acceptable for a default file-based store; production stores should implement proper locking.
async appendMessages(threadId: string, messages: MessageObject[]): Promise<void> {
const thread = await this.getThread(threadId);
thread.messages.push(...messages);
await this.writeFile(this.safePath(this.threadsDir, threadId), thread);
}

async createRun(
input: InputMessage[],
threadId?: string,
config?: AgentRunConfig,
metadata?: Record<string, unknown>,
): Promise<RunRecord> {
const runId = `run_${crypto.randomUUID()}`;
const record: RunRecord = {
id: runId,
object: 'thread.run',
thread_id: threadId,
status: 'queued',
input,
config,
metadata,
created_at: Math.floor(Date.now() / 1000),
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.

上面的方法没有复用

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.

已在 9951d13d 中修复:createRun 中的 Math.floor(Date.now() / 1000) 已改为复用 nowUnix() 工具函数。

};
await this.writeFile(this.safePath(this.runsDir, runId), record);
return record;
}

async getRun(runId: string): Promise<RunRecord> {
const filePath = this.safePath(this.runsDir, runId);
const data = await this.readFile(filePath);
if (!data) {
throw new Error(`Run ${runId} not found`);
}
return data as RunRecord;
}

async updateRun(runId: string, updates: Partial<RunRecord>): Promise<void> {
const run = await this.getRun(runId);
Object.assign(run, updates);
await this.writeFile(this.safePath(this.runsDir, runId), run);
}

private async writeFile(filePath: string, data: unknown): Promise<void> {
const tmpPath = `${filePath}.${crypto.randomUUID()}.tmp`;
await fs.writeFile(tmpPath, JSON.stringify(data), 'utf-8');
await fs.rename(tmpPath, filePath);
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.

这个是考虑原子操作?会有并发写吗,没有的话 fs open 然后用 fd 去写就可以了。现在这样会反复 open

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.

如果 thread_id 一样,会有并发,这里先简化么?有没有并发,其实主要看client侧怎么用的,目前初期估计不太会碰到

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.

已在 9951d13d 中简化为直接 fs.writeFile,去掉了 tmp+rename。

关于并发写:分析后发现同一进程内同一 run 的 updateRun 不会并发(cancelRunawait task.promise 等后台任务结束后才写)。真正的并发场景是 cluster mode 多 worker 共享 dataDir 时对同一 thread 的 appendMessages,这需要跨进程的文件锁才能解决。当前先保留简单实现并注释说明限制,后续可通过在 FileAgentStore 内加 withLock(基于 Node.js 22+ 的 FileHandle.lock())扩展,不影响外部接口。

}

private async readFile(filePath: string): Promise<unknown | null> {
try {
const content = await fs.readFile(filePath, 'utf-8');
return JSON.parse(content);
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.

catch 搞小点?下面 ENOENT 是针对 readfile 的。

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.

已在 9951d13d 中修复:缩小了 try 范围,只包裹 fs.readFileJSON.parse 移到 catch 外面。

} catch (err: any) {
if (err.code === 'ENOENT') {
return null;
}
throw err;
}
}
}
Loading