@conciergeprotocol/server 0.0.0-stage → 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/README.md +128 -2
- package/package.json +40 -4
- package/src/check.js +16 -0
- package/src/check.mjs +91 -0
- package/src/cli.js +9 -0
- package/src/config.mjs +111 -0
- package/src/http.mjs +43 -0
- package/src/index.d.ts +88 -0
- package/src/index.js +115 -0
- package/src/node-server.mjs +375 -0
- package/src/oauth.mjs +592 -0
- package/src/store.mjs +358 -0
- package/src/web.mjs +56 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { createServer } from 'node:http'
|
|
2
|
+
import { fail, readBody, requestOrigin, send } from './http.mjs'
|
|
3
|
+
import { newConfirmationId, newMessageId, startOfUtcDay } from './store.mjs'
|
|
4
|
+
|
|
5
|
+
const MAX_MESSAGE = 8000
|
|
6
|
+
const MAX_SUMMARY = 300
|
|
7
|
+
const CONFIRM_TTL_MS = 10 * 60_000
|
|
8
|
+
|
|
9
|
+
export function publicMessage(msg, confirmation = null) {
|
|
10
|
+
const view = { id: msg.id, status: msg.status }
|
|
11
|
+
if (msg.status === 'done') view.reply = msg.reply ?? ''
|
|
12
|
+
if (msg.status === 'failed') view.error = msg.error || { code: 'failed', message: 'The agent could not answer.' }
|
|
13
|
+
if (msg.status === 'done' && confirmation) view.confirmation = confirmation
|
|
14
|
+
view.created_at = msg.created_at
|
|
15
|
+
view.finished_at = msg.finished_at || null
|
|
16
|
+
return view
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A pending request past its time reads as expired, and only a pending one carries the link. */
|
|
20
|
+
export function publicConfirmation(c, url) {
|
|
21
|
+
const status = c.status === 'pending' && Date.parse(c.expires_at) <= Date.now() ? 'expired' : c.status
|
|
22
|
+
const view = { id: c.id, status }
|
|
23
|
+
if (status === 'pending') view.url = url
|
|
24
|
+
view.summary = c.summary
|
|
25
|
+
view.authorization_details = c.authorization_details
|
|
26
|
+
view.expires_at = c.expires_at
|
|
27
|
+
if ((status === 'done' || status === 'failed') && c.result) view.result = c.result
|
|
28
|
+
return view
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function bearer(req) {
|
|
32
|
+
const match = String(req.headers.authorization || '').match(/^Bearer\s+(\S+)$/i)
|
|
33
|
+
return match ? match[1] : ''
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function callerId(req) {
|
|
37
|
+
const match = String(req.headers['cap-from'] || '').match(/^agent:([A-Za-z0-9_.-]{1,64})$/)
|
|
38
|
+
return match ? match[1] : ''
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function outcome(value) {
|
|
42
|
+
if (value?.error) {
|
|
43
|
+
return {
|
|
44
|
+
status: 'failed',
|
|
45
|
+
reply: null,
|
|
46
|
+
error: { code: String(value.error.code || 'failed'), message: String(value.error.message || 'The agent could not answer.') },
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (typeof value?.reply === 'string') return { status: 'done', reply: value.reply, error: null }
|
|
50
|
+
return { status: 'failed', reply: null, error: { code: 'failed', message: 'The agent could not answer.' } }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `answer({ message, context, history, account, scope, caller })` resolves to `{ reply }` or `{ error: { code, message } }`.
|
|
55
|
+
* `history` is the earlier turns of the thread, oldest first, from the owner's own records.
|
|
56
|
+
* `scope` is `read` or `write` with a token, and null without one.
|
|
57
|
+
* To hold an action for the person's approval, it resolves to
|
|
58
|
+
* `{ confirm: { summary, authorization_details }, reply? }` instead.
|
|
59
|
+
* It runs on the owner. Nothing it uses is returned except the reply text.
|
|
60
|
+
*/
|
|
61
|
+
export function createCapServer({
|
|
62
|
+
store,
|
|
63
|
+
agent,
|
|
64
|
+
answer,
|
|
65
|
+
waitMs = 20_000,
|
|
66
|
+
retryAfter = 5,
|
|
67
|
+
retentionHours = 24,
|
|
68
|
+
dailyLimit = 0,
|
|
69
|
+
maxTurns = 20,
|
|
70
|
+
oauth = null,
|
|
71
|
+
publicUrl = '',
|
|
72
|
+
extraRoutes,
|
|
73
|
+
}) {
|
|
74
|
+
async function onRequest(req, res) {
|
|
75
|
+
try {
|
|
76
|
+
await route(req, res)
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (res.headersSent) return
|
|
79
|
+
const status = err.status || 500
|
|
80
|
+
fail(res, status, status === 400 ? 'invalid_request' : 'failed', status === 400 ? err.message : 'Request failed.')
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const server = createServer(onRequest)
|
|
85
|
+
|
|
86
|
+
async function view(msg, origin) {
|
|
87
|
+
if (msg.status !== 'done' || !msg.confirmation_id || !oauth) return publicMessage(msg)
|
|
88
|
+
const confirmation = await store.confirmationById(msg.confirmation_id)
|
|
89
|
+
return publicMessage(msg, confirmation ? publicConfirmation(confirmation, oauth.confirmUrl(origin, confirmation.id)) : null)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function respond(res, msg, origin) {
|
|
93
|
+
if (msg.status === 'working') {
|
|
94
|
+
send(res, 202, publicMessage(msg), {
|
|
95
|
+
headers: { location: `/cap/v1/messages/${msg.id}`, 'retry-after': String(retryAfter) },
|
|
96
|
+
})
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
send(res, 200, await view(msg, origin))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const failedWith = (code, message) => ({ status: 'failed', reply: null, error: { code, message } })
|
|
103
|
+
|
|
104
|
+
// The action is not run here. It waits for the person on the confirm page.
|
|
105
|
+
async function hold(value, msg, scope, origin) {
|
|
106
|
+
if (!oauth?.canConfirm || !msg.account) {
|
|
107
|
+
return { result: failedWith('failed', `${agent.name} cannot hold an action for approval.`) }
|
|
108
|
+
}
|
|
109
|
+
if (scope !== 'write') {
|
|
110
|
+
return { result: failedWith('write_required', 'This login can read but not make changes. Log in again and allow changes.') }
|
|
111
|
+
}
|
|
112
|
+
const summary = String(value.confirm.summary || '').trim().slice(0, MAX_SUMMARY)
|
|
113
|
+
if (!summary) return { result: failedWith('failed', 'The agent could not answer.') }
|
|
114
|
+
const details = Array.isArray(value.confirm.authorization_details) ? value.confirm.authorization_details : []
|
|
115
|
+
const id = newConfirmationId()
|
|
116
|
+
const now = Date.now()
|
|
117
|
+
await store.insertConfirmation({
|
|
118
|
+
id,
|
|
119
|
+
caller_id: msg.caller_id,
|
|
120
|
+
account: msg.account,
|
|
121
|
+
client_id: msg.client_id,
|
|
122
|
+
message_id: msg.id,
|
|
123
|
+
summary,
|
|
124
|
+
authorization_details: details,
|
|
125
|
+
expires_at: new Date(now + CONFIRM_TTL_MS).toISOString(),
|
|
126
|
+
created_at: new Date(now).toISOString(),
|
|
127
|
+
})
|
|
128
|
+
const lead = typeof value.reply === 'string' && value.reply.trim() ? value.reply.trim() : `${summary} This needs your OK first:`
|
|
129
|
+
return { id, result: { status: 'done', reply: `${lead} ${oauth.confirmUrl(origin, id)}`, error: null } }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function expired(msg) {
|
|
133
|
+
return Boolean(msg.finished_at) && Date.now() - Date.parse(msg.finished_at) > retentionHours * 3_600_000
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A thread may move from no token to a token, never from one account to another.
|
|
137
|
+
function canContinue(prev, caller, account, client) {
|
|
138
|
+
if (!prev || prev.caller_id !== caller || expired(prev)) return false
|
|
139
|
+
if (!prev.account && !prev.client_id) return true
|
|
140
|
+
return prev.account === account && prev.client_id === client
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function threadFor(previousId, caller, account, client) {
|
|
144
|
+
const first = await store.byId(previousId)
|
|
145
|
+
if (!canContinue(first, caller, account, client)) {
|
|
146
|
+
return { status: 404, code: 'not_found', message: 'previous_message_id was not found.' }
|
|
147
|
+
}
|
|
148
|
+
if (first.status === 'working') {
|
|
149
|
+
return { status: 409, code: 'conflict', message: 'The previous message is still working.' }
|
|
150
|
+
}
|
|
151
|
+
const turns = []
|
|
152
|
+
let cursor = first
|
|
153
|
+
while (cursor) {
|
|
154
|
+
if (turns.length >= maxTurns) {
|
|
155
|
+
return { status: 409, code: 'conflict', message: 'This conversation is too long. Start a new one.' }
|
|
156
|
+
}
|
|
157
|
+
turns.unshift({ message: cursor.message, context: cursor.context, reply: cursor.status === 'done' ? cursor.reply : null })
|
|
158
|
+
cursor = cursor.previous_id ? await store.byId(cursor.previous_id) : null
|
|
159
|
+
}
|
|
160
|
+
return { history: turns }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function grantFor(req, caller) {
|
|
164
|
+
if (!req.headers.authorization) return { grant: null }
|
|
165
|
+
const grant = await store.grantByToken(bearer(req))
|
|
166
|
+
if (!grant || grant.kind === 'refresh') return { status: 401, code: 'unauthorized', message: 'The token is invalid, expired, or revoked.' }
|
|
167
|
+
if (grant.client_id !== caller) return { status: 403, code: 'forbidden', message: 'This token was issued to a different agent.' }
|
|
168
|
+
return { grant }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function route(req, res) {
|
|
172
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
173
|
+
if (req.method === 'OPTIONS') {
|
|
174
|
+
send(res, 204, null, { raw: '' })
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
178
|
+
send(res, 200, { ok: true })
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
const origin = requestOrigin(req, publicUrl)
|
|
182
|
+
if (req.method === 'GET' && url.pathname === '/agent.txt') {
|
|
183
|
+
const oauthLine = agent.oauth || (oauth ? oauth.issuer(origin) : '')
|
|
184
|
+
const lines = [
|
|
185
|
+
'Protocol: CAP/1.0',
|
|
186
|
+
`Name: ${agent.name}`,
|
|
187
|
+
agent.chat ? `Chat: ${agent.chat}` : '',
|
|
188
|
+
`CAP: ${origin}/cap/v1`,
|
|
189
|
+
oauthLine ? `OAuth: ${oauthLine}` : '',
|
|
190
|
+
`Spec: ${agent.spec || 'https://conciergeprotocol.org/cap.txt'}`,
|
|
191
|
+
].filter(Boolean)
|
|
192
|
+
send(res, 200, null, { type: 'text/plain; charset=utf-8', raw: `${lines.join('\n')}\n` })
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
if (oauth && (await oauth.handle(req, res, url, origin))) return
|
|
196
|
+
if (extraRoutes && (await extraRoutes(req, res, url))) return
|
|
197
|
+
|
|
198
|
+
if (!url.pathname.startsWith('/cap/v1/')) {
|
|
199
|
+
fail(res, 404, 'not_found', 'Not found.')
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
if (req.headers['cap-version'] !== '1.0') {
|
|
203
|
+
fail(res, 400, 'version_not_supported', 'CAP-Version must be 1.0.')
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
const caller = callerId(req)
|
|
207
|
+
if (!caller) {
|
|
208
|
+
fail(res, 400, 'invalid_request', 'CAP-From must be agent:{id}.')
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (req.method === 'POST' && url.pathname === '/cap/v1/messages') {
|
|
213
|
+
await sendMessage(req, res, caller, origin)
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
const readMatch = url.pathname.match(/^\/cap\/v1\/messages\/([^/]+)$/)
|
|
217
|
+
if (req.method === 'GET' && readMatch) {
|
|
218
|
+
await readMessage(req, res, caller, decodeURIComponent(readMatch[1]), origin)
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
const confirmMatch = url.pathname.match(/^\/cap\/v1\/confirmations\/([^/]+)$/)
|
|
222
|
+
if (req.method === 'GET' && confirmMatch) {
|
|
223
|
+
await readConfirmation(req, res, caller, decodeURIComponent(confirmMatch[1]), origin)
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
fail(res, 404, 'not_found', 'Not found.')
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function sendMessage(req, res, caller, origin) {
|
|
230
|
+
if (agent.status && agent.status !== 'active') {
|
|
231
|
+
fail(res, 503, 'unavailable', `${agent.name} is not answering right now.`)
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
const access = await grantFor(req, caller)
|
|
235
|
+
if (!access.grant && access.status) {
|
|
236
|
+
fail(res, access.status, access.code, access.message)
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let body
|
|
241
|
+
try {
|
|
242
|
+
body = JSON.parse(await readBody(req) || '{}')
|
|
243
|
+
} catch {
|
|
244
|
+
fail(res, 400, 'invalid_request', 'Body must be JSON.')
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
const text = typeof body.message === 'string' ? body.message.trim() : ''
|
|
248
|
+
if (!text || text.length > MAX_MESSAGE) {
|
|
249
|
+
fail(res, 400, 'invalid_request', `message must be text, up to ${MAX_MESSAGE} characters.`)
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
if (body.context != null && (typeof body.context !== 'object' || Array.isArray(body.context))) {
|
|
253
|
+
fail(res, 400, 'invalid_request', 'context must be an object.')
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
const previousId = body.previous_message_id ?? null
|
|
257
|
+
if (previousId != null && (typeof previousId !== 'string' || !previousId)) {
|
|
258
|
+
fail(res, 400, 'invalid_request', 'previous_message_id must be a message id.')
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const account = access.grant?.account || ''
|
|
263
|
+
const client = access.grant?.client_id || ''
|
|
264
|
+
const scope = access.grant ? (access.grant.scope === 'write' ? 'write' : 'read') : null
|
|
265
|
+
const key = String(req.headers['idempotency-key'] || '')
|
|
266
|
+
if (key && !/^[A-Za-z0-9_.:-]{1,128}$/.test(key)) {
|
|
267
|
+
fail(res, 400, 'invalid_request', 'Idempotency-Key is invalid.')
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
if (key) {
|
|
271
|
+
const existing = await store.byIdempotency({ callerId: caller, account, clientId: client, key })
|
|
272
|
+
if (existing) {
|
|
273
|
+
await respond(res, existing, origin)
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let history = []
|
|
279
|
+
if (previousId) {
|
|
280
|
+
const thread = await threadFor(previousId, caller, account, client)
|
|
281
|
+
if (thread.status) {
|
|
282
|
+
fail(res, thread.status, thread.code, thread.message)
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
history = thread.history
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (dailyLimit > 0 && (await store.countSince(caller, startOfUtcDay())) >= dailyLimit) {
|
|
289
|
+
fail(res, 429, 'rate_limited', 'Too many messages from this agent today.', { 'retry-after': '3600' })
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const msg = {
|
|
294
|
+
id: newMessageId(),
|
|
295
|
+
caller_id: caller,
|
|
296
|
+
account,
|
|
297
|
+
client_id: client,
|
|
298
|
+
idempotency_key: key,
|
|
299
|
+
previous_id: previousId,
|
|
300
|
+
message: text,
|
|
301
|
+
context: body.context || {},
|
|
302
|
+
status: 'working',
|
|
303
|
+
reply: null,
|
|
304
|
+
error: null,
|
|
305
|
+
created_at: new Date().toISOString(),
|
|
306
|
+
finished_at: null,
|
|
307
|
+
}
|
|
308
|
+
const saved = await store.insert(msg)
|
|
309
|
+
if (!saved.created) {
|
|
310
|
+
await respond(res, saved.message, origin)
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const work = Promise.resolve()
|
|
315
|
+
.then(() => answer({ message: msg.message, context: msg.context, history, account: account || null, scope, caller }))
|
|
316
|
+
.catch(() => ({ error: { code: 'failed', message: 'The agent could not answer.' } }))
|
|
317
|
+
.then(async (value) => {
|
|
318
|
+
const held = value?.confirm && !value.error ? await hold(value, msg, scope, origin) : null
|
|
319
|
+
const result = held ? held.result : outcome(value)
|
|
320
|
+
const confirmationId = held?.id || null
|
|
321
|
+
const finishedAt = new Date().toISOString()
|
|
322
|
+
await store.finish(msg.id, { ...result, finishedAt, confirmationId })
|
|
323
|
+
Object.assign(msg, result, { finished_at: finishedAt, confirmation_id: confirmationId })
|
|
324
|
+
})
|
|
325
|
+
let timer
|
|
326
|
+
const waited = new Promise((resolve) => { timer = setTimeout(resolve, waitMs) })
|
|
327
|
+
await Promise.race([work, waited])
|
|
328
|
+
clearTimeout(timer)
|
|
329
|
+
await respond(res, msg, origin)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function readConfirmation(req, res, caller, id, origin) {
|
|
333
|
+
const access = await grantFor(req, caller)
|
|
334
|
+
if (!access.grant && access.status) {
|
|
335
|
+
fail(res, access.status, access.code, access.message)
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
const confirmation = oauth && access.grant ? await store.confirmationById(id) : null
|
|
339
|
+
const visible = confirmation
|
|
340
|
+
&& confirmation.caller_id === caller
|
|
341
|
+
&& confirmation.account === access.grant.account
|
|
342
|
+
&& confirmation.client_id === access.grant.client_id
|
|
343
|
+
if (!visible) {
|
|
344
|
+
fail(res, 404, 'not_found', 'Confirmation not found.')
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
const body = publicConfirmation(confirmation, oauth.confirmUrl(origin, confirmation.id))
|
|
348
|
+
const waiting = body.status === 'pending' || body.status === 'working'
|
|
349
|
+
send(res, 200, body, waiting ? { headers: { 'retry-after': String(retryAfter) } } : {})
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function readMessage(req, res, caller, id, origin) {
|
|
353
|
+
const access = await grantFor(req, caller)
|
|
354
|
+
if (!access.grant && access.status) {
|
|
355
|
+
fail(res, access.status, access.code, access.message)
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
const msg = await store.byId(id)
|
|
359
|
+
const visible = msg
|
|
360
|
+
&& msg.caller_id === caller
|
|
361
|
+
&& msg.account === (access.grant?.account || '')
|
|
362
|
+
&& msg.client_id === (access.grant?.client_id || '')
|
|
363
|
+
if (!visible || expired(msg)) {
|
|
364
|
+
fail(res, 404, 'not_found', 'Message not found.')
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
if (msg.status === 'working') {
|
|
368
|
+
send(res, 200, publicMessage(msg), { headers: { 'retry-after': String(retryAfter) } })
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
send(res, 200, await view(msg, origin))
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return { server, handle: onRequest }
|
|
375
|
+
}
|