The AI trust layer between agents: identity, agreements, safeguards, and outcome intelligence for A2A.

Viewing files at commit 90ba0b085978Commit detailsView latest

apps/runtime-sidecar/src/index.ts 207 lines · 7.1 KB · typescript
1
import { createServer, type IncomingHttpHeaders } from 'node:http';
2
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
import { dirname } from 'node:path';
4
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
5
import {
6
  LiveSessionActivationSchema,
7
  type LiveSessionActivation,
8
} from '../../../packages/protocol/src/index.js';
9
import {
10
  createAgentRuntimeConnector,
11
  HttpAgentRuntimeAdapter,
12
  OpenClaspClient,
13
  type RuntimeSessionStore,
14
} from '../../../packages/sdk/src/index.js';
15
16
class JsonFileRuntimeSessionStore implements RuntimeSessionStore {
17
  private loading: Promise<void> | undefined;
18
  private readonly sessions = new Map<string, LiveSessionActivation>();
19
  private writes = Promise.resolve();
20
21
  private readonly key: Buffer;
22
23
  constructor(
24
    private readonly filename: string,
25
    encryptionSecret: string,
26
  ) {
27
    this.key = createHash('sha256')
28
      .update(`openclasp-runtime-sessions:${encryptionSecret}`)
29
      .digest();
30
  }
31
32
  private async load() {
33
    this.loading ??= (async () => {
34
      try {
35
        const envelope = JSON.parse(await readFile(this.filename, 'utf8')) as {
36
          version: number;
37
          iv: string;
38
          authTag: string;
39
          ciphertext: string;
40
        };
41
        if (envelope.version !== 1) throw new Error('Unsupported runtime session file');
42
        const decipher = createDecipheriv(
43
          'aes-256-gcm',
44
          this.key,
45
          Buffer.from(envelope.iv, 'base64url'),
46
        );
47
        decipher.setAuthTag(Buffer.from(envelope.authTag, 'base64url'));
48
        const plaintext = Buffer.concat([
49
          decipher.update(Buffer.from(envelope.ciphertext, 'base64url')),
50
          decipher.final(),
51
        ]).toString('utf8');
52
        const values = JSON.parse(plaintext) as unknown[];
53
        for (const value of values) {
54
          const session = LiveSessionActivationSchema.parse(value);
55
          this.sessions.set(session.interactionId, session);
56
        }
57
      } catch (error) {
58
        if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
59
      }
60
    })();
61
    await this.loading;
62
  }
63
64
  async get(interactionId: string) {
65
    await this.load();
66
    return this.sessions.get(interactionId);
67
  }
68
69
  async put(interactionId: string, session: LiveSessionActivation) {
70
    await this.load();
71
    this.sessions.set(interactionId, LiveSessionActivationSchema.parse(session));
72
    this.writes = this.writes.then(async () => {
73
      await mkdir(dirname(this.filename), { recursive: true });
74
      const temporary = `${this.filename}.${process.pid}.tmp`;
75
      const iv = randomBytes(12);
76
      const cipher = createCipheriv('aes-256-gcm', this.key, iv);
77
      const ciphertext = Buffer.concat([
78
        cipher.update(JSON.stringify([...this.sessions.values()])),
79
        cipher.final(),
80
      ]);
81
      await writeFile(
82
        temporary,
83
        JSON.stringify({
84
          version: 1,
85
          iv: iv.toString('base64url'),
86
          authTag: cipher.getAuthTag().toString('base64url'),
87
          ciphertext: ciphertext.toString('base64url'),
88
        }),
89
        { mode: 0o600 },
90
      );
91
      await rename(temporary, this.filename);
92
    });
93
    await this.writes;
94
  }
95
}
96
97
function required(name: string) {
98
  const value = process.env[name]?.trim();
99
  if (!value) throw new Error(`${name} is required`);
100
  return value;
101
}
102
103
function sdkBaseUrl(value: string) {
104
  return `${value.replace(/\/$/, '').replace(/\/v0\.1$/, '')}/v0.1`;
105
}
106
107
function requestHeaders(headers: IncomingHttpHeaders) {
108
  const output = new Headers();
109
  for (const [name, value] of Object.entries(headers)) {
110
    if (Array.isArray(value)) for (const entry of value) output.append(name, entry);
111
    else if (value !== undefined) output.set(name, value);
112
  }
113
  return output;
114
}
115
116
async function readBody(request: NodeJS.ReadableStream, maximumBytes = 2_000_000) {
117
  const chunks: Buffer[] = [];
118
  let size = 0;
119
  for await (const chunk of request) {
120
    const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
121
    size += value.byteLength;
122
    if (size > maximumBytes) throw new Error('Request body is too large');
123
    chunks.push(value);
124
  }
125
  return Buffer.concat(chunks).toString('utf8');
126
}
127
128
const accessToken = required('OPENCLASP_AGENT_TOKEN');
129
const runtimeUrl = new URL(required('OPENCLASP_RUNTIME_URL'));
130
if (runtimeUrl.protocol !== 'https:') throw new Error('OPENCLASP_RUNTIME_URL must use HTTPS');
131
const agentAdapterUrl = required('AGENT_ADAPTER_URL');
132
const openClaspUrl = process.env.OPENCLASP_URL?.trim() || 'https://openclasp.vercel.app';
133
const client = new OpenClaspClient(sdkBaseUrl(openClaspUrl), accessToken);
134
const bootstrap = await client.getRuntimeBootstrap();
135
const sessions = new JsonFileRuntimeSessionStore(
136
  process.env.OPENCLASP_SESSION_FILE?.trim() || './data/openclasp-sessions.json',
137
  process.env.OPENCLASP_SESSION_SECRET?.trim() || accessToken,
138
);
139
const adapter = new HttpAgentRuntimeAdapter(agentAdapterUrl, {
140
  ...(process.env.AGENT_ADAPTER_TOKEN?.trim()
141
    ? { bearerToken: process.env.AGENT_ADAPTER_TOKEN.trim() }
142
    : {}),
143
  timeoutMs: Number(process.env.AGENT_ADAPTER_TIMEOUT_MS ?? 30_000),
144
});
145
const handler = createAgentRuntimeConnector({
146
  agentId: bootstrap.agentId,
147
  a2aEndpoint: runtimeUrl.toString(),
148
  openClaspUrl,
149
  adapter,
150
  sessions,
151
});
152
153
let ready = false;
154
const server = createServer(async (request, response) => {
155
  const path = new URL(request.url ?? '/', runtimeUrl).pathname;
156
  if (request.method === 'GET' && (path === '/health' || path === '/ready')) {
157
    const status = path === '/health' || ready ? 200 : 503;
158
    response.writeHead(status, { 'content-type': 'application/json' });
159
    response.end(JSON.stringify({ status: status === 200 ? 'ready' : 'starting' }));
160
    return;
161
  }
162
  if (request.method !== 'POST' || path !== runtimeUrl.pathname) {
163
    response.writeHead(404, { 'content-type': 'application/json' });
164
    response.end(JSON.stringify({ error: 'not_found' }));
165
    return;
166
  }
167
  try {
168
    const body = await readBody(request);
169
    const result = await handler(
170
      new Request(runtimeUrl, {
171
        method: 'POST',
172
        headers: requestHeaders(request.headers),
173
        body,
174
      }),
175
    );
176
    const headers: Record<string, string> = {};
177
    result.headers.forEach((value, name) => (headers[name] = value));
178
    response.writeHead(result.status, headers);
179
    response.end(await result.text());
180
  } catch (error) {
181
    response.writeHead(500, { 'content-type': 'application/json' });
182
    response.end(
183
      JSON.stringify({ error: error instanceof Error ? error.message : 'runtime_failed' }),
184
    );
185
  }
186
});
187
188
const port = Number(process.env.PORT ?? 8080);
189
const host = process.env.HOST?.trim() || '0.0.0.0';
190
await new Promise<void>((resolve, reject) => {
191
  server.once('error', reject);
192
  server.listen(port, host, resolve);
193
});
194
195
await client.connectRuntime(runtimeUrl.toString());
196
ready = true;
197
console.log(`OpenClasp runtime connected for ${bootstrap.agentId} at ${runtimeUrl.toString()}`);
198
199
const heartbeat = setInterval(() => void client.heartbeatRuntime().catch(() => undefined), 60_000);
200
heartbeat.unref();
201
202
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
203
  process.once(signal, () => {
204
    clearInterval(heartbeat);
205
    server.close(() => process.exit(0));
206
  });
207
}