@neurocode-ai/server 1.18.8
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/package.json +24 -0
- package/src/api.ts +8 -0
- package/src/auth.ts +63 -0
- package/src/cors.ts +34 -0
- package/src/handlers/agent.ts +13 -0
- package/src/handlers/command.ts +8 -0
- package/src/handlers/credential.ts +22 -0
- package/src/handlers/event.ts +52 -0
- package/src/handlers/fs.ts +39 -0
- package/src/handlers/health.ts +7 -0
- package/src/handlers/integration.ts +104 -0
- package/src/handlers/location.ts +18 -0
- package/src/handlers/message.ts +81 -0
- package/src/handlers/model.ts +17 -0
- package/src/handlers/permission.ts +98 -0
- package/src/handlers/project-copy.ts +68 -0
- package/src/handlers/provider.ts +32 -0
- package/src/handlers/pty.ts +223 -0
- package/src/handlers/question.ts +62 -0
- package/src/handlers/reference.ts +8 -0
- package/src/handlers/session.ts +385 -0
- package/src/handlers/skill.ts +8 -0
- package/src/handlers.ts +40 -0
- package/src/location.ts +60 -0
- package/src/middleware/authorization.ts +58 -0
- package/src/middleware/schema-error.ts +20 -0
- package/src/middleware/session-location.ts +67 -0
- package/src/pty-environment.ts +19 -0
- package/src/routes.ts +68 -0
- package/sst-env.d.ts +10 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { SessionV2 } from "@neurocode-ai/core/session"
|
|
2
|
+
import { DateTime, Effect, Stream } from "effect"
|
|
3
|
+
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
|
4
|
+
import { Api } from "../api"
|
|
5
|
+
import { SessionsCursor } from "@neurocode-ai/protocol/groups/session"
|
|
6
|
+
import {
|
|
7
|
+
ConflictError,
|
|
8
|
+
InvalidCursorError,
|
|
9
|
+
MessageNotFoundError,
|
|
10
|
+
ServiceUnavailableError,
|
|
11
|
+
SessionNotFoundError,
|
|
12
|
+
UnknownError,
|
|
13
|
+
} from "@neurocode-ai/protocol/errors"
|
|
14
|
+
import { AbsolutePath } from "@neurocode-ai/core/schema"
|
|
15
|
+
|
|
16
|
+
const DefaultSessionsLimit = 50
|
|
17
|
+
const DefaultSessionHistoryLimit = 50
|
|
18
|
+
|
|
19
|
+
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
|
|
20
|
+
Effect.gen(function* () {
|
|
21
|
+
const session = yield* SessionV2.Service
|
|
22
|
+
|
|
23
|
+
return handlers
|
|
24
|
+
.handle(
|
|
25
|
+
"session.list",
|
|
26
|
+
Effect.fn(function* (ctx) {
|
|
27
|
+
const query =
|
|
28
|
+
ctx.query.cursor !== undefined
|
|
29
|
+
? yield* SessionsCursor.parse(ctx.query.cursor).pipe(
|
|
30
|
+
Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })),
|
|
31
|
+
)
|
|
32
|
+
: ctx.query
|
|
33
|
+
const sessions = yield* session.list({
|
|
34
|
+
...query,
|
|
35
|
+
workspaceID: query.workspace,
|
|
36
|
+
limit: ctx.query.limit ?? DefaultSessionsLimit,
|
|
37
|
+
})
|
|
38
|
+
const first = sessions[0]
|
|
39
|
+
const last = sessions.at(-1)
|
|
40
|
+
return {
|
|
41
|
+
data: sessions,
|
|
42
|
+
cursor: {
|
|
43
|
+
previous: first
|
|
44
|
+
? SessionsCursor.make({
|
|
45
|
+
...query,
|
|
46
|
+
anchor: {
|
|
47
|
+
id: first.id,
|
|
48
|
+
time: DateTime.toEpochMillis(first.time.created),
|
|
49
|
+
direction: "previous",
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
: undefined,
|
|
53
|
+
next: last
|
|
54
|
+
? SessionsCursor.make({
|
|
55
|
+
...query,
|
|
56
|
+
anchor: {
|
|
57
|
+
id: last.id,
|
|
58
|
+
time: DateTime.toEpochMillis(last.time.created),
|
|
59
|
+
direction: "next",
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
: undefined,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}),
|
|
66
|
+
)
|
|
67
|
+
.handle(
|
|
68
|
+
"session.create",
|
|
69
|
+
Effect.fn(function* (ctx) {
|
|
70
|
+
return {
|
|
71
|
+
data: yield* session.create({
|
|
72
|
+
id: ctx.payload.id,
|
|
73
|
+
agent: ctx.payload.agent,
|
|
74
|
+
model: ctx.payload.model,
|
|
75
|
+
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
|
|
76
|
+
}),
|
|
77
|
+
}
|
|
78
|
+
}),
|
|
79
|
+
)
|
|
80
|
+
.handle(
|
|
81
|
+
"session.active",
|
|
82
|
+
Effect.fn(function* () {
|
|
83
|
+
return {
|
|
84
|
+
data: Object.fromEntries(
|
|
85
|
+
Array.from(yield* session.active, (sessionID) => [sessionID, { type: "running" as const }]),
|
|
86
|
+
),
|
|
87
|
+
}
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
.handle(
|
|
91
|
+
"session.get",
|
|
92
|
+
Effect.fn(function* (ctx) {
|
|
93
|
+
return {
|
|
94
|
+
data: yield* session.get(ctx.params.sessionID).pipe(
|
|
95
|
+
Effect.catchTag(
|
|
96
|
+
"Session.NotFoundError",
|
|
97
|
+
(error) =>
|
|
98
|
+
new SessionNotFoundError({
|
|
99
|
+
sessionID: error.sessionID,
|
|
100
|
+
message: `Session not found: ${error.sessionID}`,
|
|
101
|
+
}),
|
|
102
|
+
),
|
|
103
|
+
),
|
|
104
|
+
}
|
|
105
|
+
}),
|
|
106
|
+
)
|
|
107
|
+
.handle(
|
|
108
|
+
"session.switchAgent",
|
|
109
|
+
Effect.fn(function* (ctx) {
|
|
110
|
+
yield* session.switchAgent({ sessionID: ctx.params.sessionID, agent: ctx.payload.agent }).pipe(
|
|
111
|
+
Effect.catchTag("Session.NotFoundError", (error) =>
|
|
112
|
+
Effect.fail(
|
|
113
|
+
new SessionNotFoundError({
|
|
114
|
+
sessionID: error.sessionID,
|
|
115
|
+
message: `Session not found: ${error.sessionID}`,
|
|
116
|
+
}),
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
return HttpApiSchema.NoContent.make()
|
|
121
|
+
}),
|
|
122
|
+
)
|
|
123
|
+
.handle(
|
|
124
|
+
"session.switchModel",
|
|
125
|
+
Effect.fn(function* (ctx) {
|
|
126
|
+
yield* session.switchModel({ sessionID: ctx.params.sessionID, model: ctx.payload.model }).pipe(
|
|
127
|
+
Effect.catchTag("Session.NotFoundError", (error) =>
|
|
128
|
+
Effect.fail(
|
|
129
|
+
new SessionNotFoundError({
|
|
130
|
+
sessionID: error.sessionID,
|
|
131
|
+
message: `Session not found: ${error.sessionID}`,
|
|
132
|
+
}),
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
return HttpApiSchema.NoContent.make()
|
|
137
|
+
}),
|
|
138
|
+
)
|
|
139
|
+
.handle(
|
|
140
|
+
"session.prompt",
|
|
141
|
+
Effect.fn(function* (ctx) {
|
|
142
|
+
return {
|
|
143
|
+
data: yield* session
|
|
144
|
+
.prompt({
|
|
145
|
+
sessionID: ctx.params.sessionID,
|
|
146
|
+
id: ctx.payload.id,
|
|
147
|
+
prompt: ctx.payload.prompt,
|
|
148
|
+
delivery: ctx.payload.delivery,
|
|
149
|
+
resume: ctx.payload.resume,
|
|
150
|
+
})
|
|
151
|
+
.pipe(
|
|
152
|
+
Effect.catchTag("Session.NotFoundError", (error) =>
|
|
153
|
+
Effect.fail(
|
|
154
|
+
new SessionNotFoundError({
|
|
155
|
+
sessionID: error.sessionID,
|
|
156
|
+
message: `Session not found: ${error.sessionID}`,
|
|
157
|
+
}),
|
|
158
|
+
),
|
|
159
|
+
),
|
|
160
|
+
Effect.catchTag("Session.PromptConflictError", (error) =>
|
|
161
|
+
Effect.fail(
|
|
162
|
+
new ConflictError({
|
|
163
|
+
message: `Prompt message ID conflicts with an existing durable record: ${error.messageID}`,
|
|
164
|
+
resource: error.messageID,
|
|
165
|
+
}),
|
|
166
|
+
),
|
|
167
|
+
),
|
|
168
|
+
),
|
|
169
|
+
}
|
|
170
|
+
}),
|
|
171
|
+
)
|
|
172
|
+
.handle(
|
|
173
|
+
"session.compact",
|
|
174
|
+
Effect.fn(function* (ctx) {
|
|
175
|
+
yield* session.compact({ sessionID: ctx.params.sessionID }).pipe(
|
|
176
|
+
Effect.catchTag("Session.NotFoundError", (error) =>
|
|
177
|
+
Effect.fail(
|
|
178
|
+
new SessionNotFoundError({
|
|
179
|
+
sessionID: error.sessionID,
|
|
180
|
+
message: `Session not found: ${error.sessionID}`,
|
|
181
|
+
}),
|
|
182
|
+
),
|
|
183
|
+
),
|
|
184
|
+
Effect.catchTag("Session.OperationUnavailableError", (error) =>
|
|
185
|
+
Effect.fail(
|
|
186
|
+
new ServiceUnavailableError({
|
|
187
|
+
message: `Session ${error.operation} is not available yet`,
|
|
188
|
+
service: `session.${error.operation}`,
|
|
189
|
+
}),
|
|
190
|
+
),
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
return HttpApiSchema.NoContent.make()
|
|
194
|
+
}),
|
|
195
|
+
)
|
|
196
|
+
.handle(
|
|
197
|
+
"session.wait",
|
|
198
|
+
Effect.fn(function* (ctx) {
|
|
199
|
+
yield* session.wait(ctx.params.sessionID).pipe(
|
|
200
|
+
Effect.catchTag("Session.NotFoundError", (error) =>
|
|
201
|
+
Effect.fail(
|
|
202
|
+
new SessionNotFoundError({
|
|
203
|
+
sessionID: error.sessionID,
|
|
204
|
+
message: `Session not found: ${error.sessionID}`,
|
|
205
|
+
}),
|
|
206
|
+
),
|
|
207
|
+
),
|
|
208
|
+
Effect.catchTag("Session.OperationUnavailableError", (error) =>
|
|
209
|
+
Effect.fail(
|
|
210
|
+
new ServiceUnavailableError({
|
|
211
|
+
message: `Session ${error.operation} is not available yet`,
|
|
212
|
+
service: `session.${error.operation}`,
|
|
213
|
+
}),
|
|
214
|
+
),
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
return HttpApiSchema.NoContent.make()
|
|
218
|
+
}),
|
|
219
|
+
)
|
|
220
|
+
.handle(
|
|
221
|
+
"session.revert.stage",
|
|
222
|
+
Effect.fn(function* (ctx) {
|
|
223
|
+
return {
|
|
224
|
+
data: yield* session.revert.stage({ ...ctx.params, ...ctx.payload }).pipe(
|
|
225
|
+
Effect.catchTag(
|
|
226
|
+
"Session.NotFoundError",
|
|
227
|
+
(error) =>
|
|
228
|
+
new SessionNotFoundError({
|
|
229
|
+
sessionID: error.sessionID,
|
|
230
|
+
message: `Session not found: ${error.sessionID}`,
|
|
231
|
+
}),
|
|
232
|
+
),
|
|
233
|
+
Effect.catchTag(
|
|
234
|
+
"Session.MessageNotFoundError",
|
|
235
|
+
(error) =>
|
|
236
|
+
new MessageNotFoundError({
|
|
237
|
+
sessionID: error.sessionID,
|
|
238
|
+
messageID: error.messageID,
|
|
239
|
+
message: `Message not found: ${error.messageID}`,
|
|
240
|
+
}),
|
|
241
|
+
),
|
|
242
|
+
Effect.catchTag("Snapshot.Error", (error) => {
|
|
243
|
+
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
|
244
|
+
return Effect.logError("failed to stage session revert", { cause: error }).pipe(
|
|
245
|
+
Effect.andThen(
|
|
246
|
+
Effect.fail(
|
|
247
|
+
new UnknownError({
|
|
248
|
+
message: "Unexpected server error. Check server logs for details.",
|
|
249
|
+
ref,
|
|
250
|
+
}),
|
|
251
|
+
),
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
}),
|
|
255
|
+
),
|
|
256
|
+
}
|
|
257
|
+
}),
|
|
258
|
+
)
|
|
259
|
+
.handle(
|
|
260
|
+
"session.revert.clear",
|
|
261
|
+
Effect.fn(function* (ctx) {
|
|
262
|
+
yield* session.revert.clear(ctx.params.sessionID).pipe(
|
|
263
|
+
Effect.catchTag(
|
|
264
|
+
"Session.NotFoundError",
|
|
265
|
+
(error) =>
|
|
266
|
+
new SessionNotFoundError({
|
|
267
|
+
sessionID: error.sessionID,
|
|
268
|
+
message: `Session not found: ${error.sessionID}`,
|
|
269
|
+
}),
|
|
270
|
+
),
|
|
271
|
+
Effect.catchTag("Snapshot.Error", (error) => {
|
|
272
|
+
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
|
273
|
+
return Effect.logError("failed to clear session revert", { cause: error }).pipe(
|
|
274
|
+
Effect.andThen(
|
|
275
|
+
Effect.fail(
|
|
276
|
+
new UnknownError({
|
|
277
|
+
message: "Unexpected server error. Check server logs for details.",
|
|
278
|
+
ref,
|
|
279
|
+
}),
|
|
280
|
+
),
|
|
281
|
+
),
|
|
282
|
+
)
|
|
283
|
+
}),
|
|
284
|
+
)
|
|
285
|
+
return HttpApiSchema.NoContent.make()
|
|
286
|
+
}),
|
|
287
|
+
)
|
|
288
|
+
.handle(
|
|
289
|
+
"session.revert.commit",
|
|
290
|
+
Effect.fn(function* (ctx) {
|
|
291
|
+
yield* session.revert.commit(ctx.params.sessionID).pipe(
|
|
292
|
+
Effect.catchTag(
|
|
293
|
+
"Session.NotFoundError",
|
|
294
|
+
(error) =>
|
|
295
|
+
new SessionNotFoundError({
|
|
296
|
+
sessionID: error.sessionID,
|
|
297
|
+
message: `Session not found: ${error.sessionID}`,
|
|
298
|
+
}),
|
|
299
|
+
),
|
|
300
|
+
)
|
|
301
|
+
return HttpApiSchema.NoContent.make()
|
|
302
|
+
}),
|
|
303
|
+
)
|
|
304
|
+
.handle(
|
|
305
|
+
"session.context",
|
|
306
|
+
Effect.fn(function* (ctx) {
|
|
307
|
+
return {
|
|
308
|
+
data: yield* session.context(ctx.params.sessionID).pipe(
|
|
309
|
+
Effect.catchTag("Session.NotFoundError", (error) =>
|
|
310
|
+
Effect.fail(
|
|
311
|
+
new SessionNotFoundError({
|
|
312
|
+
sessionID: error.sessionID,
|
|
313
|
+
message: `Session not found: ${error.sessionID}`,
|
|
314
|
+
}),
|
|
315
|
+
),
|
|
316
|
+
),
|
|
317
|
+
Effect.catchTag("Session.MessageDecodeError", (error) => {
|
|
318
|
+
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
|
319
|
+
return Effect.logError("failed to decode session message").pipe(
|
|
320
|
+
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
|
|
321
|
+
Effect.andThen(
|
|
322
|
+
Effect.fail(
|
|
323
|
+
new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }),
|
|
324
|
+
),
|
|
325
|
+
),
|
|
326
|
+
)
|
|
327
|
+
}),
|
|
328
|
+
),
|
|
329
|
+
}
|
|
330
|
+
}),
|
|
331
|
+
)
|
|
332
|
+
.handle(
|
|
333
|
+
"session.history",
|
|
334
|
+
Effect.fn(function* (ctx) {
|
|
335
|
+
return yield* session
|
|
336
|
+
.history({
|
|
337
|
+
sessionID: ctx.params.sessionID,
|
|
338
|
+
after: ctx.query.after,
|
|
339
|
+
limit: ctx.query.limit ?? DefaultSessionHistoryLimit,
|
|
340
|
+
})
|
|
341
|
+
.pipe(
|
|
342
|
+
Effect.map((page) => ({
|
|
343
|
+
data: page.events,
|
|
344
|
+
hasMore: page.hasMore,
|
|
345
|
+
})),
|
|
346
|
+
Effect.catchTag(
|
|
347
|
+
"Session.NotFoundError",
|
|
348
|
+
(error) =>
|
|
349
|
+
new SessionNotFoundError({
|
|
350
|
+
sessionID: error.sessionID,
|
|
351
|
+
message: `Session not found: ${error.sessionID}`,
|
|
352
|
+
}),
|
|
353
|
+
),
|
|
354
|
+
)
|
|
355
|
+
}),
|
|
356
|
+
)
|
|
357
|
+
.handle(
|
|
358
|
+
"session.events",
|
|
359
|
+
Effect.fn((ctx) =>
|
|
360
|
+
Effect.succeed(
|
|
361
|
+
session.events({ sessionID: ctx.params.sessionID, after: ctx.query.after }).pipe(Stream.orDie),
|
|
362
|
+
),
|
|
363
|
+
),
|
|
364
|
+
)
|
|
365
|
+
.handle(
|
|
366
|
+
"session.interrupt",
|
|
367
|
+
Effect.fn(function* (ctx) {
|
|
368
|
+
yield* session.interrupt(ctx.params.sessionID)
|
|
369
|
+
return HttpApiSchema.NoContent.make()
|
|
370
|
+
}),
|
|
371
|
+
)
|
|
372
|
+
.handle(
|
|
373
|
+
"session.message",
|
|
374
|
+
Effect.fn(function* (ctx) {
|
|
375
|
+
const message = yield* session.message(ctx.params)
|
|
376
|
+
if (message) return { data: message }
|
|
377
|
+
return yield* new MessageNotFoundError({
|
|
378
|
+
sessionID: ctx.params.sessionID,
|
|
379
|
+
messageID: ctx.params.messageID,
|
|
380
|
+
message: `Message not found: ${ctx.params.messageID}`,
|
|
381
|
+
})
|
|
382
|
+
}),
|
|
383
|
+
)
|
|
384
|
+
}),
|
|
385
|
+
)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { SkillV2 } from "@neurocode-ai/core/skill"
|
|
2
|
+
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
|
3
|
+
import { Api } from "../api"
|
|
4
|
+
import { response } from "../location"
|
|
5
|
+
|
|
6
|
+
export const SkillHandler = HttpApiBuilder.group(Api, "server.skill", (handlers) =>
|
|
7
|
+
handlers.handle("skill.list", () => response(SkillV2.Service.use((skill) => skill.list()))),
|
|
8
|
+
)
|
package/src/handlers.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Layer } from "effect"
|
|
2
|
+
import { MessageHandler } from "./handlers/message"
|
|
3
|
+
import { ModelHandler } from "./handlers/model"
|
|
4
|
+
import { ProviderHandler } from "./handlers/provider"
|
|
5
|
+
import { SessionHandler } from "./handlers/session"
|
|
6
|
+
import { PermissionHandler } from "./handlers/permission"
|
|
7
|
+
import { FileSystemHandler } from "./handlers/fs"
|
|
8
|
+
import { CommandHandler } from "./handlers/command"
|
|
9
|
+
import { SkillHandler } from "./handlers/skill"
|
|
10
|
+
import { EventHandler } from "./handlers/event"
|
|
11
|
+
import { AgentHandler } from "./handlers/agent"
|
|
12
|
+
import { HealthHandler } from "./handlers/health"
|
|
13
|
+
import { PtyHandler } from "./handlers/pty"
|
|
14
|
+
import { QuestionHandler } from "./handlers/question"
|
|
15
|
+
import { ReferenceHandler } from "./handlers/reference"
|
|
16
|
+
import { LocationHandler } from "./handlers/location"
|
|
17
|
+
import { IntegrationHandler } from "./handlers/integration"
|
|
18
|
+
import { CredentialHandler } from "./handlers/credential"
|
|
19
|
+
import { ProjectCopyHandler } from "./handlers/project-copy"
|
|
20
|
+
|
|
21
|
+
export const handlers = Layer.mergeAll(
|
|
22
|
+
HealthHandler,
|
|
23
|
+
LocationHandler,
|
|
24
|
+
AgentHandler,
|
|
25
|
+
SessionHandler,
|
|
26
|
+
MessageHandler,
|
|
27
|
+
ModelHandler,
|
|
28
|
+
ProviderHandler,
|
|
29
|
+
IntegrationHandler,
|
|
30
|
+
CredentialHandler,
|
|
31
|
+
PermissionHandler,
|
|
32
|
+
FileSystemHandler,
|
|
33
|
+
CommandHandler,
|
|
34
|
+
SkillHandler,
|
|
35
|
+
EventHandler,
|
|
36
|
+
PtyHandler,
|
|
37
|
+
QuestionHandler,
|
|
38
|
+
ReferenceHandler,
|
|
39
|
+
ProjectCopyHandler,
|
|
40
|
+
)
|
package/src/location.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Location } from "@neurocode-ai/core/location"
|
|
2
|
+
import { LocationServiceMap } from "@neurocode-ai/core/location-services"
|
|
3
|
+
import { AbsolutePath } from "@neurocode-ai/core/schema"
|
|
4
|
+
import { WorkspaceV2 } from "@neurocode-ai/core/workspace"
|
|
5
|
+
import { Effect, Layer } from "effect"
|
|
6
|
+
import { HttpServerRequest } from "effect/unstable/http"
|
|
7
|
+
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
|
8
|
+
|
|
9
|
+
export type LocationServices = Layer.Success<ReturnType<(typeof LocationServiceMap.Service)["get"]>>
|
|
10
|
+
|
|
11
|
+
export class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware, { provides: LocationServices }>()(
|
|
12
|
+
"@neurocode/HttpApiLocation",
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
|
|
16
|
+
return Effect.gen(function* () {
|
|
17
|
+
const location = yield* Location.Service
|
|
18
|
+
return {
|
|
19
|
+
location: new Location.Info({
|
|
20
|
+
directory: location.directory,
|
|
21
|
+
workspaceID: location.workspaceID,
|
|
22
|
+
project: location.project,
|
|
23
|
+
}),
|
|
24
|
+
data: yield* data,
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
|
|
30
|
+
const query = new URL(request.url, "http://localhost").searchParams
|
|
31
|
+
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
|
|
32
|
+
const directory =
|
|
33
|
+
query.get("location[directory]") ||
|
|
34
|
+
(request.headers["x-opencode-directory"] ? decode(request.headers["x-opencode-directory"]) : process.cwd())
|
|
35
|
+
return Location.Ref.make({
|
|
36
|
+
directory: AbsolutePath.make(directory),
|
|
37
|
+
workspaceID: workspaceID ? WorkspaceV2.ID.make(workspaceID) : undefined,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function decode(input: string) {
|
|
42
|
+
try {
|
|
43
|
+
return decodeURIComponent(input)
|
|
44
|
+
} catch {
|
|
45
|
+
return input
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const layer = Layer.effect(
|
|
50
|
+
LocationMiddleware,
|
|
51
|
+
Effect.gen(function* () {
|
|
52
|
+
const locations = yield* LocationServiceMap.Service
|
|
53
|
+
return LocationMiddleware.of((effect) =>
|
|
54
|
+
Effect.gen(function* () {
|
|
55
|
+
const request = yield* HttpServerRequest.HttpServerRequest
|
|
56
|
+
return yield* effect.pipe(Effect.provide(locations.get(ref(request))))
|
|
57
|
+
}),
|
|
58
|
+
)
|
|
59
|
+
}),
|
|
60
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { ServerAuth } from "../auth"
|
|
2
|
+
import { UnauthorizedError } from "@neurocode-ai/protocol/errors"
|
|
3
|
+
import { Authorization } from "@neurocode-ai/protocol/middleware/authorization"
|
|
4
|
+
export { Authorization } from "@neurocode-ai/protocol/middleware/authorization"
|
|
5
|
+
import { hasPtyConnectTicketURL } from "@neurocode-ai/protocol/groups/pty"
|
|
6
|
+
import { Effect, Encoding, Layer, Redacted } from "effect"
|
|
7
|
+
import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
|
8
|
+
|
|
9
|
+
const AUTH_TOKEN_QUERY = "auth_token"
|
|
10
|
+
const WWW_AUTHENTICATE = 'Basic realm="Secure Area"'
|
|
11
|
+
|
|
12
|
+
function emptyCredential() {
|
|
13
|
+
return { username: "", password: Redacted.make("") }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function decodeCredential(input: string) {
|
|
17
|
+
return Effect.fromResult(Encoding.decodeBase64String(input)).pipe(
|
|
18
|
+
Effect.match({
|
|
19
|
+
onFailure: emptyCredential,
|
|
20
|
+
onSuccess: (header) => {
|
|
21
|
+
const separator = header.indexOf(":")
|
|
22
|
+
if (separator === -1) return emptyCredential()
|
|
23
|
+
return { username: header.slice(0, separator), password: Redacted.make(header.slice(separator + 1)) }
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) {
|
|
30
|
+
const url = new URL(request.url, "http://localhost")
|
|
31
|
+
const token = url.searchParams.get(AUTH_TOKEN_QUERY)
|
|
32
|
+
if (token) return decodeCredential(token)
|
|
33
|
+
const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "")
|
|
34
|
+
if (match) return decodeCredential(match[1])
|
|
35
|
+
return Effect.succeed(emptyCredential())
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const authorizationLayer = Layer.effect(
|
|
39
|
+
Authorization,
|
|
40
|
+
Effect.gen(function* () {
|
|
41
|
+
const config = yield* ServerAuth.Config
|
|
42
|
+
if (!ServerAuth.required(config)) return Authorization.of((effect) => effect)
|
|
43
|
+
return Authorization.of((effect) =>
|
|
44
|
+
Effect.gen(function* () {
|
|
45
|
+
const request = yield* HttpServerRequest.HttpServerRequest
|
|
46
|
+
// Browsers cannot set headers on WebSocket upgrades, so a ticketed PTY connect skips
|
|
47
|
+
// credential checks here; the connect handler consumes and validates the ticket.
|
|
48
|
+
if (hasPtyConnectTicketURL(new URL(request.url, "http://localhost"))) return yield* effect
|
|
49
|
+
const credential = yield* credentialFromRequest(request)
|
|
50
|
+
if (ServerAuth.authorized(credential, config)) return yield* effect
|
|
51
|
+
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
|
|
52
|
+
Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
|
|
53
|
+
)
|
|
54
|
+
return yield* new UnauthorizedError({ message: "Authentication required" })
|
|
55
|
+
}),
|
|
56
|
+
)
|
|
57
|
+
}),
|
|
58
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
|
3
|
+
import { InvalidRequestError } from "@neurocode-ai/protocol/errors"
|
|
4
|
+
import { SchemaErrorMiddleware } from "@neurocode-ai/protocol/middleware/schema-error"
|
|
5
|
+
export { SchemaErrorMiddleware } from "@neurocode-ai/protocol/middleware/schema-error"
|
|
6
|
+
|
|
7
|
+
const REASON_LIMIT = 1024
|
|
8
|
+
|
|
9
|
+
function truncateReason(reason: string) {
|
|
10
|
+
if (reason.length <= REASON_LIMIT) return reason
|
|
11
|
+
return reason.slice(0, REASON_LIMIT) + `... (${reason.length - REASON_LIMIT} more chars)`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => {
|
|
15
|
+
const reason = truncateReason(error.cause.message)
|
|
16
|
+
return Effect.logWarning("schema rejection").pipe(
|
|
17
|
+
Effect.annotateLogs({ kind: error.kind, reason }),
|
|
18
|
+
Effect.andThen(Effect.fail(new InvalidRequestError({ message: reason, kind: error.kind }))),
|
|
19
|
+
)
|
|
20
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Database } from "@neurocode-ai/core/database/database"
|
|
2
|
+
import { LocationServiceMap } from "@neurocode-ai/core/location-services"
|
|
3
|
+
import { Location } from "@neurocode-ai/core/location"
|
|
4
|
+
import { AbsolutePath } from "@neurocode-ai/core/schema"
|
|
5
|
+
import { SessionV2 } from "@neurocode-ai/core/session"
|
|
6
|
+
import { SessionTable } from "@neurocode-ai/core/session/sql"
|
|
7
|
+
import { WorkspaceV2 } from "@neurocode-ai/core/workspace"
|
|
8
|
+
import { eq } from "drizzle-orm"
|
|
9
|
+
import { Effect, Layer, Schema } from "effect"
|
|
10
|
+
import { HttpRouter } from "effect/unstable/http"
|
|
11
|
+
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
|
12
|
+
import { InvalidRequestError, SessionNotFoundError } from "@neurocode-ai/protocol/errors"
|
|
13
|
+
import type { LocationServices } from "../location"
|
|
14
|
+
|
|
15
|
+
export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
|
|
16
|
+
SessionLocationMiddleware,
|
|
17
|
+
{ provides: LocationServices }
|
|
18
|
+
>()("@neurocode/HttpApiSessionLocation", {
|
|
19
|
+
error: [InvalidRequestError, SessionNotFoundError],
|
|
20
|
+
}) {}
|
|
21
|
+
|
|
22
|
+
const decodeSessionID = Schema.decodeUnknownEffect(SessionV2.ID)
|
|
23
|
+
|
|
24
|
+
export const sessionLocationLayer = Layer.effect(
|
|
25
|
+
SessionLocationMiddleware,
|
|
26
|
+
Effect.gen(function* () {
|
|
27
|
+
const { db } = yield* Database.Service
|
|
28
|
+
const locations = yield* LocationServiceMap.Service
|
|
29
|
+
|
|
30
|
+
return SessionLocationMiddleware.of((effect) =>
|
|
31
|
+
Effect.gen(function* () {
|
|
32
|
+
const route = yield* HttpRouter.RouteContext
|
|
33
|
+
const sessionID = yield* decodeSessionID(route.params.sessionID).pipe(
|
|
34
|
+
Effect.mapError(
|
|
35
|
+
() =>
|
|
36
|
+
new InvalidRequestError({
|
|
37
|
+
message: "Invalid session ID",
|
|
38
|
+
field: "sessionID",
|
|
39
|
+
}),
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
const row = yield* db
|
|
43
|
+
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
|
|
44
|
+
.from(SessionTable)
|
|
45
|
+
.where(eq(SessionTable.id, sessionID))
|
|
46
|
+
.get()
|
|
47
|
+
.pipe(Effect.orDie)
|
|
48
|
+
if (!row)
|
|
49
|
+
return yield* new SessionNotFoundError({
|
|
50
|
+
sessionID,
|
|
51
|
+
message: `Session not found: ${sessionID}`,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
return yield* effect.pipe(
|
|
55
|
+
Effect.provide(
|
|
56
|
+
locations.get(
|
|
57
|
+
Location.Ref.make({
|
|
58
|
+
directory: AbsolutePath.make(row.directory),
|
|
59
|
+
workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined,
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
}),
|
|
65
|
+
)
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export * as PtyEnvironment from "./pty-environment"
|
|
2
|
+
|
|
3
|
+
import { Context, Effect, Layer } from "effect"
|
|
4
|
+
import { makeGlobalNode } from "@neurocode-ai/core/effect/app-node"
|
|
5
|
+
|
|
6
|
+
export interface Interface {
|
|
7
|
+
readonly get: (input: { directory: string; cwd: string }) => Effect.Effect<Record<string, string>>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class Service extends Context.Service<Service, Interface>()("@neurocode/ServerPtyEnvironment") {}
|
|
11
|
+
|
|
12
|
+
export const layer = Layer.succeed(
|
|
13
|
+
Service,
|
|
14
|
+
Service.of({
|
|
15
|
+
get: () => Effect.succeed({}),
|
|
16
|
+
}),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|