@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,32 @@
|
|
|
1
|
+
import { Catalog } from "@neurocode-ai/core/catalog"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
|
4
|
+
import { Api } from "../api"
|
|
5
|
+
import { ProviderNotFoundError } from "@neurocode-ai/protocol/errors"
|
|
6
|
+
import { response } from "../location"
|
|
7
|
+
|
|
8
|
+
export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) =>
|
|
9
|
+
Effect.gen(function* () {
|
|
10
|
+
return handlers
|
|
11
|
+
.handle(
|
|
12
|
+
"provider.list",
|
|
13
|
+
Effect.fn(function* () {
|
|
14
|
+
const catalog = yield* Catalog.Service
|
|
15
|
+
return yield* response(catalog.provider.available())
|
|
16
|
+
}),
|
|
17
|
+
)
|
|
18
|
+
.handle(
|
|
19
|
+
"provider.get",
|
|
20
|
+
Effect.fn(function* (ctx) {
|
|
21
|
+
const catalog = yield* Catalog.Service
|
|
22
|
+
const provider = yield* catalog.provider.get(ctx.params.providerID)
|
|
23
|
+
if (!provider)
|
|
24
|
+
return yield* new ProviderNotFoundError({
|
|
25
|
+
providerID: ctx.params.providerID,
|
|
26
|
+
message: `Provider not found: ${ctx.params.providerID}`,
|
|
27
|
+
})
|
|
28
|
+
return yield* response(Effect.succeed(provider))
|
|
29
|
+
}),
|
|
30
|
+
)
|
|
31
|
+
}),
|
|
32
|
+
)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { Pty } from "@neurocode-ai/core/pty"
|
|
2
|
+
import { PtyProtocol } from "@neurocode-ai/core/pty/protocol"
|
|
3
|
+
import { PtyTicket } from "@neurocode-ai/core/pty/ticket"
|
|
4
|
+
import { Location } from "@neurocode-ai/core/location"
|
|
5
|
+
import { Effect, Queue } from "effect"
|
|
6
|
+
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
|
7
|
+
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
|
8
|
+
import * as Socket from "effect/unstable/socket/Socket"
|
|
9
|
+
import { Api } from "../api"
|
|
10
|
+
import { CorsConfig, isAllowedRequestOrigin } from "../cors"
|
|
11
|
+
import { ForbiddenError, PtyNotFoundError } from "@neurocode-ai/protocol/errors"
|
|
12
|
+
import {
|
|
13
|
+
PTY_CONNECT_TICKET_QUERY,
|
|
14
|
+
PTY_CONNECT_TOKEN_HEADER,
|
|
15
|
+
PTY_CONNECT_TOKEN_HEADER_VALUE,
|
|
16
|
+
} from "@neurocode-ai/protocol/groups/pty"
|
|
17
|
+
import { response } from "../location"
|
|
18
|
+
import { PtyEnvironment } from "../pty-environment"
|
|
19
|
+
|
|
20
|
+
const ticketScope = Effect.gen(function* () {
|
|
21
|
+
const location = yield* Location.Service
|
|
22
|
+
return { directory: location.directory as string, workspaceID: location.workspaceID }
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) =>
|
|
26
|
+
Effect.gen(function* () {
|
|
27
|
+
const tickets = yield* PtyTicket.Service
|
|
28
|
+
const cors = yield* CorsConfig
|
|
29
|
+
const environment = yield* PtyEnvironment.Service
|
|
30
|
+
|
|
31
|
+
return handlers
|
|
32
|
+
.handle(
|
|
33
|
+
"pty.list",
|
|
34
|
+
Effect.fn(function* () {
|
|
35
|
+
return yield* response((yield* Pty.Service).list())
|
|
36
|
+
}),
|
|
37
|
+
)
|
|
38
|
+
.handle(
|
|
39
|
+
"pty.create",
|
|
40
|
+
Effect.fn(function* (ctx) {
|
|
41
|
+
const pty = yield* Pty.Service
|
|
42
|
+
const location = yield* Location.Service
|
|
43
|
+
const cwd = ctx.payload.cwd || location.directory
|
|
44
|
+
return yield* response(
|
|
45
|
+
pty.create({
|
|
46
|
+
...ctx.payload,
|
|
47
|
+
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
|
|
48
|
+
cwd,
|
|
49
|
+
env: {
|
|
50
|
+
...ctx.payload.env,
|
|
51
|
+
...(yield* environment.get({ directory: location.directory, cwd })),
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
)
|
|
55
|
+
}),
|
|
56
|
+
)
|
|
57
|
+
.handle(
|
|
58
|
+
"pty.get",
|
|
59
|
+
Effect.fn(function* (ctx) {
|
|
60
|
+
const pty = yield* Pty.Service
|
|
61
|
+
return yield* response(
|
|
62
|
+
pty.get(ctx.params.ptyID).pipe(
|
|
63
|
+
Effect.catchTag(
|
|
64
|
+
"Pty.NotFoundError",
|
|
65
|
+
() =>
|
|
66
|
+
new PtyNotFoundError({
|
|
67
|
+
ptyID: ctx.params.ptyID,
|
|
68
|
+
message: `PTY session not found: ${ctx.params.ptyID}`,
|
|
69
|
+
}),
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
.handle(
|
|
76
|
+
"pty.update",
|
|
77
|
+
Effect.fn(function* (ctx) {
|
|
78
|
+
const pty = yield* Pty.Service
|
|
79
|
+
return yield* response(
|
|
80
|
+
pty
|
|
81
|
+
.update(ctx.params.ptyID, {
|
|
82
|
+
...ctx.payload,
|
|
83
|
+
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
|
|
84
|
+
})
|
|
85
|
+
.pipe(
|
|
86
|
+
Effect.catchTag(
|
|
87
|
+
"Pty.NotFoundError",
|
|
88
|
+
() =>
|
|
89
|
+
new PtyNotFoundError({
|
|
90
|
+
ptyID: ctx.params.ptyID,
|
|
91
|
+
message: `PTY session not found: ${ctx.params.ptyID}`,
|
|
92
|
+
}),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
}),
|
|
97
|
+
)
|
|
98
|
+
.handle(
|
|
99
|
+
"pty.remove",
|
|
100
|
+
Effect.fn(function* (ctx) {
|
|
101
|
+
const pty = yield* Pty.Service
|
|
102
|
+
yield* pty.remove(ctx.params.ptyID).pipe(
|
|
103
|
+
Effect.catchTag(
|
|
104
|
+
"Pty.NotFoundError",
|
|
105
|
+
() =>
|
|
106
|
+
new PtyNotFoundError({
|
|
107
|
+
ptyID: ctx.params.ptyID,
|
|
108
|
+
message: `PTY session not found: ${ctx.params.ptyID}`,
|
|
109
|
+
}),
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
return HttpApiSchema.NoContent.make()
|
|
113
|
+
}),
|
|
114
|
+
)
|
|
115
|
+
.handle(
|
|
116
|
+
"pty.connectToken",
|
|
117
|
+
Effect.fn(function* (ctx) {
|
|
118
|
+
const request = yield* HttpServerRequest.HttpServerRequest
|
|
119
|
+
// The custom header forces a CORS preflight, so cross-origin browser pages cannot
|
|
120
|
+
// mint tickets without passing the server's origin policy.
|
|
121
|
+
if (
|
|
122
|
+
request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE ||
|
|
123
|
+
!isAllowedRequestOrigin(request.headers.origin, request.headers.host, cors)
|
|
124
|
+
)
|
|
125
|
+
return yield* new ForbiddenError({ message: "Invalid PTY connect token request" })
|
|
126
|
+
const pty = yield* Pty.Service
|
|
127
|
+
yield* pty.get(ctx.params.ptyID).pipe(
|
|
128
|
+
Effect.catchTag(
|
|
129
|
+
"Pty.NotFoundError",
|
|
130
|
+
() =>
|
|
131
|
+
new PtyNotFoundError({
|
|
132
|
+
ptyID: ctx.params.ptyID,
|
|
133
|
+
message: `PTY session not found: ${ctx.params.ptyID}`,
|
|
134
|
+
}),
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
return yield* response(tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* ticketScope) }))
|
|
138
|
+
}),
|
|
139
|
+
)
|
|
140
|
+
.handleRaw(
|
|
141
|
+
"pty.connect",
|
|
142
|
+
Effect.fn("PtyHandler.connect")(function* (ctx) {
|
|
143
|
+
const pty = yield* Pty.Service
|
|
144
|
+
const exists = yield* pty.get(ctx.params.ptyID).pipe(
|
|
145
|
+
Effect.as(true),
|
|
146
|
+
Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)),
|
|
147
|
+
)
|
|
148
|
+
if (!exists) return HttpServerResponse.empty({ status: 404 })
|
|
149
|
+
|
|
150
|
+
const url = new URL(ctx.request.url, "http://localhost")
|
|
151
|
+
const ticket = url.searchParams.get(PTY_CONNECT_TICKET_QUERY)
|
|
152
|
+
if (ticket) {
|
|
153
|
+
const valid = isAllowedRequestOrigin(ctx.request.headers.origin, ctx.request.headers.host, cors)
|
|
154
|
+
? yield* tickets.consume({ ticket, ptyID: ctx.params.ptyID, ...(yield* ticketScope) })
|
|
155
|
+
: false
|
|
156
|
+
if (!valid) return HttpServerResponse.empty({ status: 403 })
|
|
157
|
+
}
|
|
158
|
+
const parsedCursor = url.searchParams.get("cursor")
|
|
159
|
+
const cursorNumber = parsedCursor === null ? undefined : Number(parsedCursor)
|
|
160
|
+
const cursor =
|
|
161
|
+
cursorNumber !== undefined && Number.isSafeInteger(cursorNumber) && cursorNumber >= -1
|
|
162
|
+
? cursorNumber
|
|
163
|
+
: undefined
|
|
164
|
+
|
|
165
|
+
const socket = yield* Effect.orDie(ctx.request.upgrade)
|
|
166
|
+
const write = yield* socket.writer
|
|
167
|
+
const closeAccepted = (event: Socket.CloseEvent) =>
|
|
168
|
+
socket
|
|
169
|
+
.runRaw(() => Effect.void, { onOpen: write(event).pipe(Effect.catch(() => Effect.void)) })
|
|
170
|
+
.pipe(
|
|
171
|
+
Effect.timeout("1 second"),
|
|
172
|
+
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
|
173
|
+
Effect.catch(() => Effect.void),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
// Outbound frames flow through one queue drained by a single writer so replay, live
|
|
177
|
+
// output, and the close frame keep their order.
|
|
178
|
+
// TODO: Integrate graceful-shutdown socket tracking before clients migrate to this route.
|
|
179
|
+
const outbox = yield* Queue.unbounded<string | Uint8Array | Socket.CloseEvent>()
|
|
180
|
+
const attachment = yield* pty
|
|
181
|
+
.attach(ctx.params.ptyID, {
|
|
182
|
+
cursor,
|
|
183
|
+
onData: (chunk) => Queue.offerUnsafe(outbox, chunk),
|
|
184
|
+
onEnd: () => Queue.offerUnsafe(outbox, new Socket.CloseEvent(1000)),
|
|
185
|
+
})
|
|
186
|
+
.pipe(
|
|
187
|
+
Effect.catchTags({
|
|
188
|
+
"Pty.NotFoundError": () =>
|
|
189
|
+
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
|
|
190
|
+
"Pty.ExitedError": () =>
|
|
191
|
+
closeAccepted(new Socket.CloseEvent(4404, "session exited")).pipe(Effect.as(undefined)),
|
|
192
|
+
}),
|
|
193
|
+
)
|
|
194
|
+
if (!attachment) return HttpServerResponse.empty()
|
|
195
|
+
|
|
196
|
+
for (const chunk of PtyProtocol.chunks(attachment.replay)) Queue.offerUnsafe(outbox, chunk)
|
|
197
|
+
Queue.offerUnsafe(outbox, PtyProtocol.metaFrame(attachment.cursor))
|
|
198
|
+
attachment.activate()
|
|
199
|
+
|
|
200
|
+
const drain = Effect.gen(function* () {
|
|
201
|
+
while (true) {
|
|
202
|
+
const item = yield* Queue.take(outbox)
|
|
203
|
+
yield* write(item)
|
|
204
|
+
if (item instanceof Socket.CloseEvent) return
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
yield* Effect.race(
|
|
209
|
+
drain,
|
|
210
|
+
socket.runRaw((message) => {
|
|
211
|
+
const decoded = PtyProtocol.decodeInput(message)
|
|
212
|
+
if (decoded !== undefined) attachment.write(decoded)
|
|
213
|
+
}),
|
|
214
|
+
).pipe(
|
|
215
|
+
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
|
216
|
+
Effect.ensuring(Effect.sync(() => attachment.detach())),
|
|
217
|
+
Effect.orDie,
|
|
218
|
+
)
|
|
219
|
+
return HttpServerResponse.empty()
|
|
220
|
+
}),
|
|
221
|
+
)
|
|
222
|
+
}),
|
|
223
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { QuestionV2 } from "@neurocode-ai/core/question"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
|
4
|
+
import { Api } from "../api"
|
|
5
|
+
import { QuestionNotFoundError } from "@neurocode-ai/protocol/errors"
|
|
6
|
+
import { response } from "../location"
|
|
7
|
+
|
|
8
|
+
function missingRequest(id: QuestionV2.ID) {
|
|
9
|
+
return new QuestionNotFoundError({ requestID: id, message: `Question request not found: ${id}` })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const QuestionHandler = HttpApiBuilder.group(Api, "server.question", (handlers) =>
|
|
13
|
+
Effect.gen(function* () {
|
|
14
|
+
const withOwnedQuestion = Effect.fnUntraced(function* <A, E>(
|
|
15
|
+
sessionID: QuestionV2.Request["sessionID"],
|
|
16
|
+
requestID: QuestionV2.ID,
|
|
17
|
+
use: (question: QuestionV2.Interface) => Effect.Effect<A, E>,
|
|
18
|
+
) {
|
|
19
|
+
const question = yield* QuestionV2.Service
|
|
20
|
+
const request = (yield* question.list()).find((request) => request.id === requestID)
|
|
21
|
+
if (!request || request.sessionID !== sessionID) return yield* missingRequest(requestID)
|
|
22
|
+
return yield* use(question)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
return handlers
|
|
26
|
+
.handle(
|
|
27
|
+
"question.request.list",
|
|
28
|
+
Effect.fn(function* () {
|
|
29
|
+
return yield* response((yield* QuestionV2.Service).list())
|
|
30
|
+
}),
|
|
31
|
+
)
|
|
32
|
+
.handle(
|
|
33
|
+
"session.question.list",
|
|
34
|
+
Effect.fn(function* (ctx) {
|
|
35
|
+
const requests = yield* (yield* QuestionV2.Service).list()
|
|
36
|
+
return { data: requests.filter((request) => request.sessionID === ctx.params.sessionID) }
|
|
37
|
+
}),
|
|
38
|
+
)
|
|
39
|
+
.handle(
|
|
40
|
+
"session.question.reply",
|
|
41
|
+
Effect.fn(function* (ctx) {
|
|
42
|
+
yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
|
|
43
|
+
question
|
|
44
|
+
.reply({ requestID: ctx.params.requestID, answers: ctx.payload.answers })
|
|
45
|
+
.pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))),
|
|
46
|
+
)
|
|
47
|
+
return HttpApiSchema.NoContent.make()
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
50
|
+
.handle(
|
|
51
|
+
"session.question.reject",
|
|
52
|
+
Effect.fn(function* (ctx) {
|
|
53
|
+
yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
|
|
54
|
+
question
|
|
55
|
+
.reject(ctx.params.requestID)
|
|
56
|
+
.pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))),
|
|
57
|
+
)
|
|
58
|
+
return HttpApiSchema.NoContent.make()
|
|
59
|
+
}),
|
|
60
|
+
)
|
|
61
|
+
}),
|
|
62
|
+
)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Reference } from "@neurocode-ai/core/reference"
|
|
2
|
+
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
|
3
|
+
import { Api } from "../api"
|
|
4
|
+
import { response } from "../location"
|
|
5
|
+
|
|
6
|
+
export const ReferenceHandler = HttpApiBuilder.group(Api, "server.reference", (handlers) =>
|
|
7
|
+
handlers.handle("reference.list", () => response(Reference.Service.use((reference) => reference.list()))),
|
|
8
|
+
)
|