定义标准协议 TypeScript 数据模型
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
parent
3a982c8f22
commit
fbcc342c5f
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-tests
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
npm-debug.log
|
||||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
FROM node:22.18.0-alpine3.22 AS dependencies
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --ignore-scripts
|
||||||
|
|
||||||
|
FROM dependencies AS test
|
||||||
|
COPY tsconfig.json tsconfig.build.json ./
|
||||||
|
COPY src ./src
|
||||||
|
COPY test ./test
|
||||||
|
CMD ["npm", "test"]
|
||||||
|
|
||||||
|
FROM dependencies AS build
|
||||||
|
COPY tsconfig.json tsconfig.build.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22.18.0-alpine3.22 AS production
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY package.json ./
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
USER node
|
||||||
|
CMD ["node", "--input-type=module", "--eval", "import('@gaza/protocol')"]
|
||||||
18
README.md
18
README.md
@ -1,2 +1,20 @@
|
|||||||
# gaza
|
# gaza
|
||||||
|
|
||||||
|
GUI 与兼容 Teamserver 之间的首版强类型 TypeScript 协议模型。本包只定义标准对象与运行时解析边界,不包含网络请求、认证或私有 C2 适配。
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { parseSession, type Session } from "@gaza/protocol";
|
||||||
|
|
||||||
|
const session: Session = parseSession(teamserverPayload);
|
||||||
|
```
|
||||||
|
|
||||||
|
`ServerId`、`ProjectId`、`SessionId` 等采用品牌类型,避免在编译期误用不同层级 ID。枚举使用前向兼容字符串表达,解析器保留未知枚举和扩展字段。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm test
|
||||||
|
docker compose build production
|
||||||
|
```
|
||||||
|
|||||||
35
compose.yaml
Normal file
35
compose.yaml
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
services:
|
||||||
|
test:
|
||||||
|
image: gaza-protocol-test:local
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: test
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
- /app/dist
|
||||||
|
- /app/dist-tests
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
build:
|
||||||
|
image: gaza-protocol-build:local
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: build
|
||||||
|
command: ["npm", "run", "build"]
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
- /app/dist
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
production:
|
||||||
|
image: gaza-protocol:local
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: production
|
||||||
|
read_only: true
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
47
package-lock.json
generated
Normal file
47
package-lock.json
generated
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "@gaza/protocol",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@gaza/protocol",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "22.17.2",
|
||||||
|
"typescript": "5.9.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.17.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.2.tgz",
|
||||||
|
"integrity": "sha512-gL6z5N9Jm9mhY+U2KXZpteb+09zyffliRkZyZOHODGATyC5B1Jt/7TzuuiLkFsSUMLbS1OLmlj/E+/3KF4Q/4w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
|
||||||
|
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
package.json
Normal file
28
package.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@gaza/protocol",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./fixtures": {
|
||||||
|
"types": "./dist/fixtures/index.d.ts",
|
||||||
|
"import": "./dist/fixtures/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"check": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "npm run build && tsc -p tsconfig.json && node --test dist-tests/test/**/*.test.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "22.17.2",
|
||||||
|
"typescript": "5.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/fixtures/index.ts
Normal file
18
src/fixtures/index.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import type { Session } from "../protocol.js";
|
||||||
|
|
||||||
|
/** UI 迁移期可直接使用的最小 Session fixture。 */
|
||||||
|
export const demoSession = {
|
||||||
|
server_id: "demo-server",
|
||||||
|
project_id: "demo-project",
|
||||||
|
session_id: "demo-session",
|
||||||
|
name: "演示会话",
|
||||||
|
state: "ONLINE",
|
||||||
|
hostname: "demo-host",
|
||||||
|
username: "operator",
|
||||||
|
os: "linux",
|
||||||
|
architecture: "amd64",
|
||||||
|
first_seen_at: "2026-08-03T00:00:00Z",
|
||||||
|
last_active_at: "2026-08-03T00:00:05Z",
|
||||||
|
tags: ["fixture"],
|
||||||
|
capabilities: ["session.overview"],
|
||||||
|
} as unknown as Session;
|
||||||
2
src/index.ts
Normal file
2
src/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./protocol.js";
|
||||||
|
export * from "./parser.js";
|
||||||
49
src/parser.ts
Normal file
49
src/parser.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import type { ProjectContext, ProtocolVersion, Session, SessionContext } from "./protocol.js";
|
||||||
|
|
||||||
|
export class ProtocolParseError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ProtocolParseError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasString(value: Record<string, unknown>, key: string): boolean {
|
||||||
|
return typeof value[key] === "string" && value[key] !== "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isProtocolVersion(value: unknown): value is ProtocolVersion {
|
||||||
|
return isRecord(value) && ["major", "minor", "patch"].every(
|
||||||
|
(key) => Number.isInteger(value[key]) && (value[key] as number) >= 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isProjectContext(value: unknown): value is ProjectContext {
|
||||||
|
return isRecord(value) && hasString(value, "server_id") && hasString(value, "project_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSessionContext(value: unknown): value is SessionContext {
|
||||||
|
return isProjectContext(value) && hasString(value, "session_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSession(value: unknown): value is Session {
|
||||||
|
if (!isRecord(value) || !isSessionContext(value)) return false;
|
||||||
|
return hasString(value, "name")
|
||||||
|
&& hasString(value, "state")
|
||||||
|
&& hasString(value, "first_seen_at")
|
||||||
|
&& hasString(value, "last_active_at")
|
||||||
|
&& Array.isArray(value.tags)
|
||||||
|
&& value.tags.every((tag) => typeof tag === "string")
|
||||||
|
&& Array.isArray(value.capabilities)
|
||||||
|
&& value.capabilities.every((capability) => typeof capability === "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSession(value: unknown): Session {
|
||||||
|
if (!isSession(value)) {
|
||||||
|
throw new ProtocolParseError("无效的 Session:缺少上下文字段或标准字段类型错误");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
166
src/protocol.ts
Normal file
166
src/protocol.ts
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
/** 协议对象允许保留服务端新增字段,GUI 不应因未知字段而拒绝整个对象。 */
|
||||||
|
export interface Extensible {
|
||||||
|
readonly [extension: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const brand: unique symbol;
|
||||||
|
export type Brand<Value, Name extends string> = Value & { readonly [brand]: Name };
|
||||||
|
export type ServerId = Brand<string, "ServerId">;
|
||||||
|
export type ProjectId = Brand<string, "ProjectId">;
|
||||||
|
export type SessionId = Brand<string, "SessionId">;
|
||||||
|
export type TaskId = Brand<string, "TaskId">;
|
||||||
|
export type EventId = Brand<string, "EventId">;
|
||||||
|
export type ArtifactId = Brand<string, "ArtifactId">;
|
||||||
|
export type AuditId = Brand<string, "AuditId">;
|
||||||
|
export type CapabilityId = Brand<string, "CapabilityId">;
|
||||||
|
|
||||||
|
export type Timestamp = Brand<string, "Timestamp">;
|
||||||
|
export type Cursor = Brand<string, "Cursor">;
|
||||||
|
|
||||||
|
/** 已知值保留自动补全,同时允许服务端返回未来新增值。 */
|
||||||
|
export type ForwardCompatible<Known extends string> = Known | (string & {});
|
||||||
|
|
||||||
|
export interface ProtocolVersion extends Extensible {
|
||||||
|
readonly major: number;
|
||||||
|
readonly minor: number;
|
||||||
|
readonly patch: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProtocolEnvelope extends Extensible {
|
||||||
|
readonly protocol_version: ProtocolVersion;
|
||||||
|
readonly request_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerContext extends Extensible {
|
||||||
|
readonly server_id: ServerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectContext extends ServerContext {
|
||||||
|
readonly project_id: ProjectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionContext extends ProjectContext {
|
||||||
|
readonly session_id: SessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageRequest extends Extensible {
|
||||||
|
readonly cursor?: Cursor;
|
||||||
|
readonly page_size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageInfo extends Extensible {
|
||||||
|
readonly next_cursor?: Cursor;
|
||||||
|
readonly has_more: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Page<T> extends Extensible {
|
||||||
|
readonly items: readonly T[];
|
||||||
|
readonly page_info: PageInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectionState = ForwardCompatible<
|
||||||
|
| "DISCONNECTED" | "CONNECTING" | "TLS_HANDSHAKE" | "AUTHENTICATING"
|
||||||
|
| "NEGOTIATING" | "SYNCHRONIZING" | "CONNECTED" | "DEGRADED"
|
||||||
|
| "RECONNECTING" | "AUTH_EXPIRED" | "FAILED"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface Teamserver extends ServerContext, Extensible {
|
||||||
|
readonly name: string;
|
||||||
|
readonly protocol_version: ProtocolVersion;
|
||||||
|
readonly connection_state: ConnectionState;
|
||||||
|
readonly capabilities: readonly CapabilityId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project extends ProjectContext, Extensible {
|
||||||
|
readonly name: string;
|
||||||
|
readonly description?: string;
|
||||||
|
readonly capabilities: readonly CapabilityId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionState = ForwardCompatible<"ONLINE" | "OFFLINE" | "DORMANT" | "LOST">;
|
||||||
|
|
||||||
|
export interface Session extends SessionContext, Extensible {
|
||||||
|
readonly name: string;
|
||||||
|
readonly state: SessionState;
|
||||||
|
readonly hostname?: string;
|
||||||
|
readonly username?: string;
|
||||||
|
readonly os?: string;
|
||||||
|
readonly architecture?: string;
|
||||||
|
readonly first_seen_at: Timestamp;
|
||||||
|
readonly last_active_at: Timestamp;
|
||||||
|
readonly tags: readonly string[];
|
||||||
|
readonly capabilities: readonly CapabilityId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JsonSchema = Readonly<Record<string, unknown>>;
|
||||||
|
|
||||||
|
export interface Capability extends ProjectContext, Extensible {
|
||||||
|
readonly capability_id: CapabilityId;
|
||||||
|
readonly name: string;
|
||||||
|
readonly description?: string;
|
||||||
|
readonly input_schema: JsonSchema;
|
||||||
|
readonly output_schema?: JsonSchema;
|
||||||
|
readonly supports_batch: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaskState = ForwardCompatible<
|
||||||
|
"PENDING" | "RUNNING" | "SUCCEEDED" | "FAILED" | "CANCELLED"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface Task extends SessionContext, Extensible {
|
||||||
|
readonly task_id: TaskId;
|
||||||
|
readonly capability_id: CapabilityId;
|
||||||
|
readonly state: TaskState;
|
||||||
|
readonly cancellable: boolean;
|
||||||
|
readonly created_at: Timestamp;
|
||||||
|
readonly updated_at: Timestamp;
|
||||||
|
readonly result?: unknown;
|
||||||
|
readonly error?: ProtocolError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventContext extends ServerContext, Extensible {
|
||||||
|
readonly project_id?: ProjectId;
|
||||||
|
readonly session_id?: SessionId;
|
||||||
|
readonly task_id?: TaskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Event extends Extensible {
|
||||||
|
readonly event_id: EventId;
|
||||||
|
readonly type: ForwardCompatible<"SESSION_UPDATED" | "TASK_UPDATED" | "TASK_OUTPUT" | "ARTIFACT_CREATED">;
|
||||||
|
readonly timestamp: Timestamp;
|
||||||
|
readonly cursor: Cursor;
|
||||||
|
readonly sequence?: number;
|
||||||
|
readonly context: EventContext;
|
||||||
|
readonly payload: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Artifact extends SessionContext, Extensible {
|
||||||
|
readonly artifact_id: ArtifactId;
|
||||||
|
readonly task_id?: TaskId;
|
||||||
|
readonly name: string;
|
||||||
|
readonly media_type: string;
|
||||||
|
readonly size_bytes: number;
|
||||||
|
readonly sha256?: string;
|
||||||
|
readonly created_at: Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditRecord extends ProjectContext, Extensible {
|
||||||
|
readonly audit_id: AuditId;
|
||||||
|
readonly actor: string;
|
||||||
|
readonly action: string;
|
||||||
|
readonly target: Readonly<Record<string, string>>;
|
||||||
|
readonly parameter_summary?: Readonly<Record<string, unknown>>;
|
||||||
|
readonly outcome: ForwardCompatible<"SUCCEEDED" | "FAILED" | "DENIED">;
|
||||||
|
readonly request_id: string;
|
||||||
|
readonly timestamp: Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProtocolError extends Extensible {
|
||||||
|
readonly code: ForwardCompatible<
|
||||||
|
"NETWORK" | "TLS" | "AUTHENTICATION" | "INCOMPATIBLE_VERSION" |
|
||||||
|
"PERMISSION_DENIED" | "RATE_LIMITED" | "TIMEOUT" | "INVALID_MESSAGE" | "INTERNAL"
|
||||||
|
>;
|
||||||
|
readonly message: string;
|
||||||
|
readonly retryable: boolean;
|
||||||
|
readonly details?: Readonly<Record<string, unknown>>;
|
||||||
|
}
|
||||||
22
test/parser.test.ts
Normal file
22
test/parser.test.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { demoSession } from "../src/fixtures/index.js";
|
||||||
|
import { isProtocolVersion, isSession, parseSession, ProtocolParseError } from "../src/index.js";
|
||||||
|
|
||||||
|
test("接受完整 Session 并保留未知枚举和扩展字段", () => {
|
||||||
|
const futureSession = { ...demoSession, state: "SUSPENDED_BY_POLICY", vendor_status: 42 };
|
||||||
|
const parsed = parseSession(futureSession);
|
||||||
|
assert.equal(parsed.state, "SUSPENDED_BY_POLICY");
|
||||||
|
assert.equal(parsed.vendor_status, 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("拒绝缺少 project_id 的跨上下文 Session", () => {
|
||||||
|
const { project_id: _projectId, ...invalid } = demoSession;
|
||||||
|
assert.equal(isSession(invalid), false);
|
||||||
|
assert.throws(() => parseSession(invalid), ProtocolParseError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("协议版本必须由非负整数构成", () => {
|
||||||
|
assert.equal(isProtocolVersion({ major: 1, minor: 0, patch: 0, future: true }), true);
|
||||||
|
assert.equal(isProtocolVersion({ major: 1, minor: -1, patch: 0 }), false);
|
||||||
|
});
|
||||||
9
tsconfig.build.json
Normal file
9
tsconfig.build.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["test"]
|
||||||
|
}
|
||||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "dist-tests",
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user