shraga 0.1.87 → 0.1.89

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.
@@ -1,229 +0,0 @@
1
- /**
2
- * paraFeature — the sender half of the para-li external-agent lane.
3
- *
4
- * Mirrors `slackFeature` exactly in shape: one `ServerFeature` that (a) mounts an ingress route and
5
- * (b) subscribes the owner-notice event bus so deploy / self-upgrade / downtime notices reach the
6
- * medium. Slack is untouched and the two coexist — both subscribe the same bus, neither knows about
7
- * the other, and a notice is delivered to each independently.
8
- *
9
- * TRANSPORT. para-li POSTs one turn here and we stream the answer BACK to it over signed webhook
10
- * calls (see `streamer.ts`), rather than holding this response open. para-li's caller is a Bodify
11
- * trigger whose lifetime is the turn, so a multi-minute agent run held on one response body dies to
12
- * a proxy idle timeout with a half-written row. Two independent requests also give the proactive
13
- * lane the same transport for free.
14
- *
15
- * TRUST. The turn request is authenticated with an ordinary shraga API key (`POST /api/api-keys`),
16
- * so reaching this route requires a credential the owner minted. The callback URL + secret arrive
17
- * IN that authenticated request — para-li tells us where to answer and with what, per turn, which
18
- * is what makes a rotated webhook secret take effect on the very next message with no config here.
19
- */
20
- import type { ServerFeature, FeatureContext } from '../features.ts';
21
- import crypto from 'node:crypto';
22
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
23
- import { subscribeEvents } from '../events/bus.ts';
24
- import { streamChat } from '../claude.ts';
25
- import { getMcpConfig } from '../mcp.ts';
26
- import { dataPath } from '../paths.ts';
27
- import {
28
- appendMessage, upsertSession, setRunStatus, acquireSessionLock, releaseSessionLock,
29
- type ConvBlock,
30
- } from '../sessions.ts';
31
- import { validateApiKey } from '../api-keys.ts';
32
- import { isOwnerEmail } from '../notify-owners.ts';
33
- import { ParaStreamer, postProactive, type ParaCallback } from './streamer.ts';
34
-
35
- interface DeployNotice { kind: 'deploy'; owners: { name?: string; slackId: string }[]; text: string }
36
-
37
- /** Last known para conversation per connection — the proactive lane's destination.
38
- *
39
- * Learned from the first turn rather than configured: para-li already tells us the conv and the
40
- * callback on every turn, so a second source of truth would only be a thing to drift. Persisted
41
- * because a deploy notice fires right after a RESTART, which is exactly when an in-memory map is
42
- * empty — the one moment the feature has to work. Lives beside `api-keys.json` in the data dir and
43
- * holds the webhook secret, so it inherits that file's protection, no more and no less. */
44
- const LINKS_PATH = dataPath('para-links.json');
45
- /** `uid` is the shraga user whose API key opened this link. It is the OWNER of the entry — see
46
- * `rememberLink`.
47
- *
48
- * `email` is that user's address, recorded so the PROACTIVE lane can answer "is this link's user
49
- * an owner of this deployment?" — `OWNERS` is an email list, and a uid does not join to it. It is
50
- * taken from `validateApiKey`, never from the request body. A link written before this field
51
- * existed has no email and is therefore not an owner: it receives no notices until its next turn
52
- * refreshes the entry. */
53
- export type Link = ParaCallback & { convId: string; at: number; uid: string; email?: string };
54
-
55
- export function loadLinks(): Record<string, Link> {
56
- if (!existsSync(LINKS_PATH)) return {};
57
- try { return JSON.parse(readFileSync(LINKS_PATH, 'utf-8')); } catch (err) {
58
- console.warn('[para] links file unreadable, starting empty:', (err as Error).message);
59
- return {};
60
- }
61
- }
62
- /** Record (or refresh) a connection's callback.
63
- *
64
- * ONE USER OWNS A connId. `connId` is chosen by the caller, so without this an API key for user A
65
- * could claim a connId already linked by user B and re-point every future PROACTIVE notice
66
- * (deploy reports, self-upgrade outcomes) at A's URL + secret. An API key is already full agent
67
- * access to its own user, so this is not a privilege boundary being invented — it is the one
68
- * cross-user step that access does not otherwise imply, so it is refused rather than logged. */
69
- function rememberLink(link: Link): void {
70
- try {
71
- const all = loadLinks();
72
- const prev = all[link.connId];
73
- if (prev?.uid && prev.uid !== link.uid) {
74
- console.warn(`[para] refusing to re-link ${link.connId}: owned by another user`);
75
- return;
76
- }
77
- all[link.connId] = link;
78
- mkdirSync(dataPath(''), { recursive: true });
79
- writeFileSync(LINKS_PATH, JSON.stringify(all, null, 2));
80
- } catch (err) {
81
- console.warn('[para] could not persist link:', (err as Error).message);
82
- }
83
- }
84
-
85
- /** Run one turn, streaming into the para row. Errors settle the row visibly — the owner must never
86
- * be left watching a "typing…" placeholder that will never resolve. */
87
- async function runParaTurn(args: {
88
- callback: ParaCallback; convId: string; msgId: string; sessionId: string; prompt: string;
89
- uid: string; userEmail: string; sendSegments: boolean;
90
- }): Promise<void> {
91
- const { callback, convId, msgId, sessionId, prompt, uid, userEmail, sendSegments } = args;
92
- const streamer = new ParaStreamer({ callback, convId, msgId, sendSegments });
93
- const abortController = new AbortController();
94
-
95
- // Lock origin is 'api': the union in sessions.ts is a closed set ('web'|'slack'|'scheduler'|
96
- // 'api') and this is an authenticated API caller. Widening it just to label the medium would
97
- // touch recovery and status code paths for no behavioural gain.
98
- if (!acquireSessionLock(sessionId, 'api', abortController)) {
99
- // sessionId === convId, so this is genuinely "you sent two messages into the same thread while
100
- // the first was still running". Say so rather than dropping it silently.
101
- await streamer.fail('That conversation is already processing a message — wait for it to finish.');
102
- return;
103
- }
104
- upsertSession(sessionId, prompt, { uid, email: userEmail });
105
- appendMessage(sessionId, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'text', text: prompt }], channel: 'para' });
106
- setRunStatus(sessionId, 'running', 'web');
107
-
108
- const blocks: ConvBlock[] = [];
109
- let text = '';
110
- try {
111
- for await (const ev of streamChat({
112
- prompt, sessionId, uid, userEmail,
113
- mcpServers: getMcpConfig(uid),
114
- abortController,
115
- context: { source: 'para', user: userEmail },
116
- onPermissionRequest: async () => ({ allow: true }),
117
- })) {
118
- if (ev.type === 'text_delta') { text += ev.text; streamer.feed({ type: 'text_delta', text: ev.text }); }
119
- else if (ev.type === 'tool_use') {
120
- if (text) { blocks.push({ type: 'text', text }); text = ''; }
121
- blocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
122
- streamer.feed({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
123
- }
124
- else if (ev.type === 'tool_result') {
125
- blocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
126
- // The `running` → `completed`/`error` transition. Fed unconditionally; the streamer ignores
127
- // it unless the receiver negotiated segments.
128
- streamer.feed({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output, isError: ev.isError });
129
- }
130
- else if (ev.type === 'done') break;
131
- else if (ev.type === 'error') {
132
- if (text) { blocks.push({ type: 'text', text }); text = ''; }
133
- blocks.push({ type: 'error', text: ev.message });
134
- await streamer.fail(ev.message);
135
- return;
136
- }
137
- }
138
- if (text) blocks.push({ type: 'text', text });
139
- await streamer.finish();
140
- } catch (err) {
141
- console.error('[para] turn failed:', (err as Error).message);
142
- await streamer.fail((err as Error).message || 'agent error');
143
- } finally {
144
- // The transcript is persisted whatever happened, so the shraga UI and the next turn's context
145
- // see the same history para saw.
146
- if (blocks.length) appendMessage(sessionId, { id: crypto.randomUUID(), role: 'assistant', blocks });
147
- if (releaseSessionLock(sessionId, abortController)) setRunStatus(sessionId, 'idle');
148
- }
149
- }
150
-
151
- let mounted = false;
152
- let busSubscribed = false;
153
-
154
- export const paraFeature: ServerFeature = {
155
- name: 'para',
156
-
157
- // No capability flag. `flags` is the seam's way to tell the CLIENT a surface exists, and nothing
158
- // in the client gates on para — the lane is driven entirely by para.li calling in. Declaring one
159
- // would be dead public surface on /api/features (slackFeature declares none for the same reason).
160
-
161
- register(ctx: FeatureContext): void {
162
- // Owner notices → the linked para conversations OF THIS DEPLOYMENT'S OWNERS. Keyed on the
163
- // notice KIND, not the source, for the reason spelled out in slackFeature: self-upgrade emits
164
- // under its own source and a source-gated subscriber silently dropped every one of them.
165
- //
166
- // WHY NOT `payload.owners`. That field is `{name?, slackId}[]` — the SLACK join, computed by
167
- // `resolveOwners` as OWNERS ∩ contacts-that-have-a-Slack-id. A para link carries no Slack id,
168
- // so the field is unmatchable here. Unfiltered, this loop posted every deploy / self-upgrade /
169
- // data-sync report to EVERY entry in para-links.json — and any shraga user with an API key
170
- // gets an entry on their first turn (`rememberLink`). The `uid` guard does not help: it stops
171
- // STEALING another user's connId, not adding your own.
172
- // The join that works is the one OWNERS is actually expressed in — the email of the API key
173
- // that opened the link — checked with the same `isOwnerEmail` that backs `resolveOwners`.
174
- if (!ctx.passive && !busSubscribed) {
175
- busSubscribed = true;
176
- subscribeEvents((evt) => {
177
- const payload = evt.payload as DeployNotice;
178
- if (payload?.kind !== 'deploy' || !payload.text) return;
179
- for (const link of Object.values(loadLinks())) {
180
- if (!isOwnerEmail(link.email)) continue;
181
- postProactive({ url: link.url, secret: link.secret, connId: link.connId }, link.convId, payload.text)
182
- .then((ok) => console.log(`[para] owner notice ${ok ? 'delivered' : 'FAILED'} → ${link.convId}`))
183
- .catch((err) => console.warn('[para] owner notice failed:', (err as Error).message));
184
- }
185
- });
186
- }
187
-
188
- if (ctx.passive || mounted) return;
189
- mounted = true;
190
-
191
- ctx.app.post('/api/para/turn', (req, res) => {
192
- const bearer = /^Bearer\s+(.+)$/i.exec(req.get('authorization') ?? '')?.[1];
193
- const caller = bearer ? validateApiKey(bearer) : null;
194
- if (!caller) return void res.status(401).json({ error: 'unauthorized' });
195
-
196
- const { connId, convId, sessionId, msgId, prompt, callback, accepts } = req.body as {
197
- connId?: string; convId?: string; sessionId?: string; msgId?: string; prompt?: string;
198
- callback?: { url?: string; secret?: string };
199
- /** Receiver capability negotiation. `'segments'` means "I can store and render structured
200
- * tool segments" — see `ParaStreamerOptions.sendSegments`. ABSENT means no: a para-li that
201
- * predates this field, and a lane (groups) that deliberately declines, both fall back to
202
- * the flattened `_🔧 Tool_` markers in the text. */
203
- accepts?: unknown;
204
- };
205
- if (!connId || !convId || !msgId || !prompt) return void res.status(400).json({ error: 'connId, convId, msgId and prompt are required' });
206
- if (!callback?.url || !callback?.secret) return void res.status(400).json({ error: 'callback.url and callback.secret are required' });
207
- try {
208
- const u = new URL(callback.url);
209
- // We hold the owner's credential and will POST to whatever this says, so it is validated
210
- // here too rather than trusted because the request authenticated.
211
- if (u.protocol !== 'https:' && u.hostname !== 'localhost' && u.hostname !== '127.0.0.1') throw new Error('https required');
212
- } catch { return void res.status(400).json({ error: 'callback.url must be a valid HTTPS URL' }); }
213
-
214
- const cb: ParaCallback = { url: callback.url, secret: callback.secret, connId };
215
- rememberLink({ ...cb, convId, at: Date.now(), uid: caller.uid, email: caller.email });
216
-
217
- // ACCEPT, then run. The answer arrives on the callback, so holding this response open would
218
- // only give para-li's trigger a socket to time out on.
219
- res.json({ status: 'accepted', sessionId: sessionId || convId });
220
- void runParaTurn({
221
- callback: cb, convId, msgId, sessionId: sessionId || convId, prompt,
222
- uid: caller.uid, userEmail: caller.email,
223
- sendSegments: Array.isArray(accepts) && accepts.includes('segments'),
224
- });
225
- });
226
-
227
- console.log('[para] turn ingress mounted at POST /api/para/turn');
228
- },
229
- };