diff --git a/packages/client/src/contract.ts b/packages/client/src/contract.ts index 65666f7289..d072c9e35e 100644 --- a/packages/client/src/contract.ts +++ b/packages/client/src/contract.ts @@ -26,6 +26,7 @@ export const groupNames = { "server.provider": "providers", "server.integration": "integrations", "server.credential": "credentials", + "server.privacy": "privacy", "server.permission": "permissions", "server.fs": "files", "server.command": "commands", diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 84a4787678..95b8193992 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -406,36 +406,55 @@ const Endpoint9_1 = (raw: RawClient["server.credential"]) => (input: Endpoint9_1 const adaptGroup9 = (raw: RawClient["server.credential"]) => ({ update: Endpoint9_0(raw), remove: Endpoint9_1(raw) }) -type Endpoint10_0Request = Parameters[0] +type Endpoint10_0Request = Parameters[0] type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["location"] } -const Endpoint10_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint10_0Input) => +const Endpoint10_0 = (raw: RawClient["server.privacy"]) => (input?: Endpoint10_0Input) => + raw["privacy.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint10_1Request = Parameters[0] +type Endpoint10_1Input = { + readonly location?: Endpoint10_1Request["query"]["location"] + readonly mode: Endpoint10_1Request["payload"]["mode"] + readonly model?: Endpoint10_1Request["payload"]["model"] +} +const Endpoint10_1 = (raw: RawClient["server.privacy"]) => (input: Endpoint10_1Input) => + raw["privacy.set"]({ + query: { location: input["location"] }, + payload: { mode: input["mode"], model: input["model"] }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup10 = (raw: RawClient["server.privacy"]) => ({ get: Endpoint10_0(raw), set: Endpoint10_1(raw) }) + +type Endpoint11_0Request = Parameters[0] +type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] } +const Endpoint11_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint11_0Input) => raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_1Request = Parameters[0] -type Endpoint10_1Input = { readonly projectID?: Endpoint10_1Request["query"]["projectID"] } -const Endpoint10_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint10_1Input) => +type Endpoint11_1Request = Parameters[0] +type Endpoint11_1Input = { readonly projectID?: Endpoint11_1Request["query"]["projectID"] } +const Endpoint11_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint11_1Input) => raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint10_2Request = Parameters[0] -type Endpoint10_2Input = { readonly id: Endpoint10_2Request["params"]["id"] } -const Endpoint10_2 = (raw: RawClient["server.permission"]) => (input: Endpoint10_2Input) => +type Endpoint11_2Request = Parameters[0] +type Endpoint11_2Input = { readonly id: Endpoint11_2Request["params"]["id"] } +const Endpoint11_2 = (raw: RawClient["server.permission"]) => (input: Endpoint11_2Input) => raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_3Request = Parameters[0] -type Endpoint10_3Input = { - readonly sessionID: Endpoint10_3Request["params"]["sessionID"] - readonly id?: Endpoint10_3Request["payload"]["id"] - readonly action: Endpoint10_3Request["payload"]["action"] - readonly resources: Endpoint10_3Request["payload"]["resources"] - readonly save?: Endpoint10_3Request["payload"]["save"] - readonly metadata?: Endpoint10_3Request["payload"]["metadata"] - readonly source?: Endpoint10_3Request["payload"]["source"] - readonly agent?: Endpoint10_3Request["payload"]["agent"] -} -const Endpoint10_3 = (raw: RawClient["server.permission"]) => (input: Endpoint10_3Input) => +type Endpoint11_3Request = Parameters[0] +type Endpoint11_3Input = { + readonly sessionID: Endpoint11_3Request["params"]["sessionID"] + readonly id?: Endpoint11_3Request["payload"]["id"] + readonly action: Endpoint11_3Request["payload"]["action"] + readonly resources: Endpoint11_3Request["payload"]["resources"] + readonly save?: Endpoint11_3Request["payload"]["save"] + readonly metadata?: Endpoint11_3Request["payload"]["metadata"] + readonly source?: Endpoint11_3Request["payload"]["source"] + readonly agent?: Endpoint11_3Request["payload"]["agent"] +} +const Endpoint11_3 = (raw: RawClient["server.permission"]) => (input: Endpoint11_3Input) => raw["session.permission.create"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -452,87 +471,87 @@ const Endpoint10_3 = (raw: RawClient["server.permission"]) => (input: Endpoint10 Effect.map((value) => value.data), ) -type Endpoint10_4Request = Parameters[0] -type Endpoint10_4Input = { readonly sessionID: Endpoint10_4Request["params"]["sessionID"] } -const Endpoint10_4 = (raw: RawClient["server.permission"]) => (input: Endpoint10_4Input) => +type Endpoint11_4Request = Parameters[0] +type Endpoint11_4Input = { readonly sessionID: Endpoint11_4Request["params"]["sessionID"] } +const Endpoint11_4 = (raw: RawClient["server.permission"]) => (input: Endpoint11_4Input) => raw["session.permission.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint10_5Request = Parameters[0] -type Endpoint10_5Input = { - readonly sessionID: Endpoint10_5Request["params"]["sessionID"] - readonly requestID: Endpoint10_5Request["params"]["requestID"] +type Endpoint11_5Request = Parameters[0] +type Endpoint11_5Input = { + readonly sessionID: Endpoint11_5Request["params"]["sessionID"] + readonly requestID: Endpoint11_5Request["params"]["requestID"] } -const Endpoint10_5 = (raw: RawClient["server.permission"]) => (input: Endpoint10_5Input) => +const Endpoint11_5 = (raw: RawClient["server.permission"]) => (input: Endpoint11_5Input) => raw["session.permission.get"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint10_6Request = Parameters[0] -type Endpoint10_6Input = { - readonly sessionID: Endpoint10_6Request["params"]["sessionID"] - readonly requestID: Endpoint10_6Request["params"]["requestID"] - readonly reply: Endpoint10_6Request["payload"]["reply"] - readonly message?: Endpoint10_6Request["payload"]["message"] +type Endpoint11_6Request = Parameters[0] +type Endpoint11_6Input = { + readonly sessionID: Endpoint11_6Request["params"]["sessionID"] + readonly requestID: Endpoint11_6Request["params"]["requestID"] + readonly reply: Endpoint11_6Request["payload"]["reply"] + readonly message?: Endpoint11_6Request["payload"]["message"] } -const Endpoint10_6 = (raw: RawClient["server.permission"]) => (input: Endpoint10_6Input) => +const Endpoint11_6 = (raw: RawClient["server.permission"]) => (input: Endpoint11_6Input) => raw["session.permission.reply"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] }, payload: { reply: input["reply"], message: input["message"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup10 = (raw: RawClient["server.permission"]) => ({ - listRequests: Endpoint10_0(raw), - listSaved: Endpoint10_1(raw), - removeSaved: Endpoint10_2(raw), - create: Endpoint10_3(raw), - list: Endpoint10_4(raw), - get: Endpoint10_5(raw), - reply: Endpoint10_6(raw), +const adaptGroup11 = (raw: RawClient["server.permission"]) => ({ + listRequests: Endpoint11_0(raw), + listSaved: Endpoint11_1(raw), + removeSaved: Endpoint11_2(raw), + create: Endpoint11_3(raw), + list: Endpoint11_4(raw), + get: Endpoint11_5(raw), + reply: Endpoint11_6(raw), }) -type Endpoint11_0Request = Parameters[0] -type Endpoint11_0Input = { - readonly location?: Endpoint11_0Request["query"]["location"] - readonly path?: Endpoint11_0Request["query"]["path"] +type Endpoint12_0Request = Parameters[0] +type Endpoint12_0Input = { + readonly location?: Endpoint12_0Request["query"]["location"] + readonly path?: Endpoint12_0Request["query"]["path"] } -const Endpoint11_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint11_0Input) => +const Endpoint12_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint12_0Input) => raw["fs.list"]({ query: { location: input?.["location"], path: input?.["path"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint11_1Request = Parameters[0] -type Endpoint11_1Input = { - readonly location?: Endpoint11_1Request["query"]["location"] - readonly query: Endpoint11_1Request["query"]["query"] - readonly type?: Endpoint11_1Request["query"]["type"] - readonly limit?: Endpoint11_1Request["query"]["limit"] +type Endpoint12_1Request = Parameters[0] +type Endpoint12_1Input = { + readonly location?: Endpoint12_1Request["query"]["location"] + readonly query: Endpoint12_1Request["query"]["query"] + readonly type?: Endpoint12_1Request["query"]["type"] + readonly limit?: Endpoint12_1Request["query"]["limit"] } -const Endpoint11_1 = (raw: RawClient["server.fs"]) => (input: Endpoint11_1Input) => +const Endpoint12_1 = (raw: RawClient["server.fs"]) => (input: Endpoint12_1Input) => raw["fs.find"]({ query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup11 = (raw: RawClient["server.fs"]) => ({ list: Endpoint11_0(raw), find: Endpoint11_1(raw) }) +const adaptGroup12 = (raw: RawClient["server.fs"]) => ({ list: Endpoint12_0(raw), find: Endpoint12_1(raw) }) -type Endpoint12_0Request = Parameters[0] -type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] } -const Endpoint12_0 = (raw: RawClient["server.command"]) => (input?: Endpoint12_0Input) => +type Endpoint13_0Request = Parameters[0] +type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] } +const Endpoint13_0 = (raw: RawClient["server.command"]) => (input?: Endpoint13_0Input) => raw["command.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup12 = (raw: RawClient["server.command"]) => ({ list: Endpoint12_0(raw) }) +const adaptGroup13 = (raw: RawClient["server.command"]) => ({ list: Endpoint13_0(raw) }) -type Endpoint13_0Request = Parameters[0] -type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] } -const Endpoint13_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint13_0Input) => +type Endpoint14_0Request = Parameters[0] +type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] } +const Endpoint14_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint14_0Input) => raw["skill.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup13 = (raw: RawClient["server.skill"]) => ({ list: Endpoint13_0(raw) }) +const adaptGroup14 = (raw: RawClient["server.skill"]) => ({ list: Endpoint14_0(raw) }) -const Endpoint14_0 = (raw: RawClient["server.event"]) => () => +const Endpoint15_0 = (raw: RawClient["server.event"]) => () => Stream.unwrap( raw["event.subscribe"]({}).pipe( Effect.mapError(mapClientError), @@ -540,23 +559,23 @@ const Endpoint14_0 = (raw: RawClient["server.event"]) => () => ), ) -const adaptGroup14 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint14_0(raw) }) +const adaptGroup15 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint15_0(raw) }) -type Endpoint15_0Request = Parameters[0] -type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] } -const Endpoint15_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint15_0Input) => +type Endpoint16_0Request = Parameters[0] +type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] } +const Endpoint16_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint16_0Input) => raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint15_1Request = Parameters[0] -type Endpoint15_1Input = { - readonly location?: Endpoint15_1Request["query"]["location"] - readonly command?: Endpoint15_1Request["payload"]["command"] - readonly args?: Endpoint15_1Request["payload"]["args"] - readonly cwd?: Endpoint15_1Request["payload"]["cwd"] - readonly title?: Endpoint15_1Request["payload"]["title"] - readonly env?: Endpoint15_1Request["payload"]["env"] +type Endpoint16_1Request = Parameters[0] +type Endpoint16_1Input = { + readonly location?: Endpoint16_1Request["query"]["location"] + readonly command?: Endpoint16_1Request["payload"]["command"] + readonly args?: Endpoint16_1Request["payload"]["args"] + readonly cwd?: Endpoint16_1Request["payload"]["cwd"] + readonly title?: Endpoint16_1Request["payload"]["title"] + readonly env?: Endpoint16_1Request["payload"]["env"] } -const Endpoint15_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint15_1Input) => +const Endpoint16_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint16_1Input) => raw["pty.create"]({ query: { location: input?.["location"] }, payload: { @@ -568,206 +587,206 @@ const Endpoint15_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint15_1Inpu }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint15_2Request = Parameters[0] -type Endpoint15_2Input = { - readonly ptyID: Endpoint15_2Request["params"]["ptyID"] - readonly location?: Endpoint15_2Request["query"]["location"] +type Endpoint16_2Request = Parameters[0] +type Endpoint16_2Input = { + readonly ptyID: Endpoint16_2Request["params"]["ptyID"] + readonly location?: Endpoint16_2Request["query"]["location"] } -const Endpoint15_2 = (raw: RawClient["server.pty"]) => (input: Endpoint15_2Input) => +const Endpoint16_2 = (raw: RawClient["server.pty"]) => (input: Endpoint16_2Input) => raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint15_3Request = Parameters[0] -type Endpoint15_3Input = { - readonly ptyID: Endpoint15_3Request["params"]["ptyID"] - readonly location?: Endpoint15_3Request["query"]["location"] - readonly title?: Endpoint15_3Request["payload"]["title"] - readonly size?: Endpoint15_3Request["payload"]["size"] +type Endpoint16_3Request = Parameters[0] +type Endpoint16_3Input = { + readonly ptyID: Endpoint16_3Request["params"]["ptyID"] + readonly location?: Endpoint16_3Request["query"]["location"] + readonly title?: Endpoint16_3Request["payload"]["title"] + readonly size?: Endpoint16_3Request["payload"]["size"] } -const Endpoint15_3 = (raw: RawClient["server.pty"]) => (input: Endpoint15_3Input) => +const Endpoint16_3 = (raw: RawClient["server.pty"]) => (input: Endpoint16_3Input) => raw["pty.update"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] }, payload: { title: input["title"], size: input["size"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint15_4Request = Parameters[0] -type Endpoint15_4Input = { - readonly ptyID: Endpoint15_4Request["params"]["ptyID"] - readonly location?: Endpoint15_4Request["query"]["location"] +type Endpoint16_4Request = Parameters[0] +type Endpoint16_4Input = { + readonly ptyID: Endpoint16_4Request["params"]["ptyID"] + readonly location?: Endpoint16_4Request["query"]["location"] } -const Endpoint15_4 = (raw: RawClient["server.pty"]) => (input: Endpoint15_4Input) => +const Endpoint16_4 = (raw: RawClient["server.pty"]) => (input: Endpoint16_4Input) => raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup15 = (raw: RawClient["server.pty"]) => ({ - list: Endpoint15_0(raw), - create: Endpoint15_1(raw), - get: Endpoint15_2(raw), - update: Endpoint15_3(raw), - remove: Endpoint15_4(raw), +const adaptGroup16 = (raw: RawClient["server.pty"]) => ({ + list: Endpoint16_0(raw), + create: Endpoint16_1(raw), + get: Endpoint16_2(raw), + update: Endpoint16_3(raw), + remove: Endpoint16_4(raw), }) -type Endpoint16_0Request = Parameters[0] -type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] } -const Endpoint16_0 = (raw: RawClient["server.question"]) => (input?: Endpoint16_0Input) => +type Endpoint17_0Request = Parameters[0] +type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] } +const Endpoint17_0 = (raw: RawClient["server.question"]) => (input?: Endpoint17_0Input) => raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint16_1Request = Parameters[0] -type Endpoint16_1Input = { readonly sessionID: Endpoint16_1Request["params"]["sessionID"] } -const Endpoint16_1 = (raw: RawClient["server.question"]) => (input: Endpoint16_1Input) => +type Endpoint17_1Request = Parameters[0] +type Endpoint17_1Input = { readonly sessionID: Endpoint17_1Request["params"]["sessionID"] } +const Endpoint17_1 = (raw: RawClient["server.question"]) => (input: Endpoint17_1Input) => raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint16_2Request = Parameters[0] -type Endpoint16_2Input = { - readonly sessionID: Endpoint16_2Request["params"]["sessionID"] - readonly requestID: Endpoint16_2Request["params"]["requestID"] - readonly answers: Endpoint16_2Request["payload"]["answers"] +type Endpoint17_2Request = Parameters[0] +type Endpoint17_2Input = { + readonly sessionID: Endpoint17_2Request["params"]["sessionID"] + readonly requestID: Endpoint17_2Request["params"]["requestID"] + readonly answers: Endpoint17_2Request["payload"]["answers"] } -const Endpoint16_2 = (raw: RawClient["server.question"]) => (input: Endpoint16_2Input) => +const Endpoint17_2 = (raw: RawClient["server.question"]) => (input: Endpoint17_2Input) => raw["session.question.reply"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] }, payload: { answers: input["answers"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint16_3Request = Parameters[0] -type Endpoint16_3Input = { - readonly sessionID: Endpoint16_3Request["params"]["sessionID"] - readonly requestID: Endpoint16_3Request["params"]["requestID"] +type Endpoint17_3Request = Parameters[0] +type Endpoint17_3Input = { + readonly sessionID: Endpoint17_3Request["params"]["sessionID"] + readonly requestID: Endpoint17_3Request["params"]["requestID"] } -const Endpoint16_3 = (raw: RawClient["server.question"]) => (input: Endpoint16_3Input) => +const Endpoint17_3 = (raw: RawClient["server.question"]) => (input: Endpoint17_3Input) => raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup16 = (raw: RawClient["server.question"]) => ({ - listRequests: Endpoint16_0(raw), - list: Endpoint16_1(raw), - reply: Endpoint16_2(raw), - reject: Endpoint16_3(raw), +const adaptGroup17 = (raw: RawClient["server.question"]) => ({ + listRequests: Endpoint17_0(raw), + list: Endpoint17_1(raw), + reply: Endpoint17_2(raw), + reject: Endpoint17_3(raw), }) -type Endpoint17_0Request = Parameters[0] -type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] } -const Endpoint17_0 = (raw: RawClient["server.browser"]) => (input?: Endpoint17_0Input) => +type Endpoint18_0Request = Parameters[0] +type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] } +const Endpoint18_0 = (raw: RawClient["server.browser"]) => (input?: Endpoint18_0Input) => raw["browser.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint17_1Request = Parameters[0] -type Endpoint17_1Input = { readonly sessionID: Endpoint17_1Request["params"]["sessionID"] } -const Endpoint17_1 = (raw: RawClient["server.browser"]) => (input: Endpoint17_1Input) => +type Endpoint18_1Request = Parameters[0] +type Endpoint18_1Input = { readonly sessionID: Endpoint18_1Request["params"]["sessionID"] } +const Endpoint18_1 = (raw: RawClient["server.browser"]) => (input: Endpoint18_1Input) => raw["session.browser.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint17_2Request = Parameters[0] -type Endpoint17_2Input = { - readonly sessionID: Endpoint17_2Request["params"]["sessionID"] - readonly requestID: Endpoint17_2Request["params"]["requestID"] - readonly value: Endpoint17_2Request["payload"]["value"] +type Endpoint18_2Request = Parameters[0] +type Endpoint18_2Input = { + readonly sessionID: Endpoint18_2Request["params"]["sessionID"] + readonly requestID: Endpoint18_2Request["params"]["requestID"] + readonly value: Endpoint18_2Request["payload"]["value"] } -const Endpoint17_2 = (raw: RawClient["server.browser"]) => (input: Endpoint17_2Input) => +const Endpoint18_2 = (raw: RawClient["server.browser"]) => (input: Endpoint18_2Input) => raw["session.browser.reply"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] }, payload: { value: input["value"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint17_3Request = Parameters[0] -type Endpoint17_3Input = { - readonly sessionID: Endpoint17_3Request["params"]["sessionID"] - readonly requestID: Endpoint17_3Request["params"]["requestID"] - readonly reason: Endpoint17_3Request["payload"]["reason"] +type Endpoint18_3Request = Parameters[0] +type Endpoint18_3Input = { + readonly sessionID: Endpoint18_3Request["params"]["sessionID"] + readonly requestID: Endpoint18_3Request["params"]["requestID"] + readonly reason: Endpoint18_3Request["payload"]["reason"] } -const Endpoint17_3 = (raw: RawClient["server.browser"]) => (input: Endpoint17_3Input) => +const Endpoint18_3 = (raw: RawClient["server.browser"]) => (input: Endpoint18_3Input) => raw["session.browser.refuse"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] }, payload: { reason: input["reason"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup17 = (raw: RawClient["server.browser"]) => ({ - listRequests: Endpoint17_0(raw), - list: Endpoint17_1(raw), - reply: Endpoint17_2(raw), - refuse: Endpoint17_3(raw), +const adaptGroup18 = (raw: RawClient["server.browser"]) => ({ + listRequests: Endpoint18_0(raw), + list: Endpoint18_1(raw), + reply: Endpoint18_2(raw), + refuse: Endpoint18_3(raw), }) -type Endpoint18_0Request = Parameters[0] -type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] } -const Endpoint18_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint18_0Input) => +type Endpoint19_0Request = Parameters[0] +type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] } +const Endpoint19_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint19_0Input) => raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup18 = (raw: RawClient["server.reference"]) => ({ list: Endpoint18_0(raw) }) +const adaptGroup19 = (raw: RawClient["server.reference"]) => ({ list: Endpoint19_0(raw) }) -type Endpoint19_0Request = Parameters[0] -type Endpoint19_0Input = { - readonly projectID: Endpoint19_0Request["params"]["projectID"] - readonly location?: Endpoint19_0Request["query"]["location"] - readonly strategy: Endpoint19_0Request["payload"]["strategy"] - readonly directory: Endpoint19_0Request["payload"]["directory"] - readonly name?: Endpoint19_0Request["payload"]["name"] +type Endpoint20_0Request = Parameters[0] +type Endpoint20_0Input = { + readonly projectID: Endpoint20_0Request["params"]["projectID"] + readonly location?: Endpoint20_0Request["query"]["location"] + readonly strategy: Endpoint20_0Request["payload"]["strategy"] + readonly directory: Endpoint20_0Request["payload"]["directory"] + readonly name?: Endpoint20_0Request["payload"]["name"] } -const Endpoint19_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint19_0Input) => +const Endpoint20_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint20_0Input) => raw["projectCopy.create"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint19_1Request = Parameters[0] -type Endpoint19_1Input = { - readonly projectID: Endpoint19_1Request["params"]["projectID"] - readonly location?: Endpoint19_1Request["query"]["location"] - readonly directory: Endpoint19_1Request["payload"]["directory"] - readonly force: Endpoint19_1Request["payload"]["force"] +type Endpoint20_1Request = Parameters[0] +type Endpoint20_1Input = { + readonly projectID: Endpoint20_1Request["params"]["projectID"] + readonly location?: Endpoint20_1Request["query"]["location"] + readonly directory: Endpoint20_1Request["payload"]["directory"] + readonly force: Endpoint20_1Request["payload"]["force"] } -const Endpoint19_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint19_1Input) => +const Endpoint20_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint20_1Input) => raw["projectCopy.remove"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, payload: { directory: input["directory"], force: input["force"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint19_2Request = Parameters[0] -type Endpoint19_2Input = { - readonly projectID: Endpoint19_2Request["params"]["projectID"] - readonly location?: Endpoint19_2Request["query"]["location"] +type Endpoint20_2Request = Parameters[0] +type Endpoint20_2Input = { + readonly projectID: Endpoint20_2Request["params"]["projectID"] + readonly location?: Endpoint20_2Request["query"]["location"] } -const Endpoint19_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint19_2Input) => +const Endpoint20_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint20_2Input) => raw["projectCopy.refresh"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup19 = (raw: RawClient["server.projectCopy"]) => ({ - create: Endpoint19_0(raw), - remove: Endpoint19_1(raw), - refresh: Endpoint19_2(raw), +const adaptGroup20 = (raw: RawClient["server.projectCopy"]) => ({ + create: Endpoint20_0(raw), + remove: Endpoint20_1(raw), + refresh: Endpoint20_2(raw), }) -type Endpoint20_0Request = Parameters[0] -type Endpoint20_0Input = { - readonly model: Endpoint20_0Request["payload"]["model"] - readonly messages: Endpoint20_0Request["payload"]["messages"] - readonly tools?: Endpoint20_0Request["payload"]["tools"] - readonly tool_choice?: Endpoint20_0Request["payload"]["tool_choice"] - readonly stream?: Endpoint20_0Request["payload"]["stream"] - readonly max_tokens?: Endpoint20_0Request["payload"]["max_tokens"] - readonly max_completion_tokens?: Endpoint20_0Request["payload"]["max_completion_tokens"] - readonly temperature?: Endpoint20_0Request["payload"]["temperature"] - readonly top_p?: Endpoint20_0Request["payload"]["top_p"] - readonly stop?: Endpoint20_0Request["payload"]["stop"] - readonly seed?: Endpoint20_0Request["payload"]["seed"] - readonly frequency_penalty?: Endpoint20_0Request["payload"]["frequency_penalty"] - readonly presence_penalty?: Endpoint20_0Request["payload"]["presence_penalty"] - readonly reasoning_effort?: Endpoint20_0Request["payload"]["reasoning_effort"] - readonly user?: Endpoint20_0Request["payload"]["user"] -} -const Endpoint20_0 = (raw: RawClient["server.chat"]) => (input: Endpoint20_0Input) => +type Endpoint21_0Request = Parameters[0] +type Endpoint21_0Input = { + readonly model: Endpoint21_0Request["payload"]["model"] + readonly messages: Endpoint21_0Request["payload"]["messages"] + readonly tools?: Endpoint21_0Request["payload"]["tools"] + readonly tool_choice?: Endpoint21_0Request["payload"]["tool_choice"] + readonly stream?: Endpoint21_0Request["payload"]["stream"] + readonly max_tokens?: Endpoint21_0Request["payload"]["max_tokens"] + readonly max_completion_tokens?: Endpoint21_0Request["payload"]["max_completion_tokens"] + readonly temperature?: Endpoint21_0Request["payload"]["temperature"] + readonly top_p?: Endpoint21_0Request["payload"]["top_p"] + readonly stop?: Endpoint21_0Request["payload"]["stop"] + readonly seed?: Endpoint21_0Request["payload"]["seed"] + readonly frequency_penalty?: Endpoint21_0Request["payload"]["frequency_penalty"] + readonly presence_penalty?: Endpoint21_0Request["payload"]["presence_penalty"] + readonly reasoning_effort?: Endpoint21_0Request["payload"]["reasoning_effort"] + readonly user?: Endpoint21_0Request["payload"]["user"] +} +const Endpoint21_0 = (raw: RawClient["server.chat"]) => (input: Endpoint21_0Input) => raw["chat.completions"]({ payload: { model: input["model"], @@ -788,7 +807,7 @@ const Endpoint20_0 = (raw: RawClient["server.chat"]) => (input: Endpoint20_0Inpu }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup20 = (raw: RawClient["server.chat"]) => ({ completions: Endpoint20_0(raw) }) +const adaptGroup21 = (raw: RawClient["server.chat"]) => ({ completions: Endpoint21_0(raw) }) const adaptClient = (raw: RawClient) => ({ health: adaptGroup0(raw["server.health"]), @@ -801,17 +820,18 @@ const adaptClient = (raw: RawClient) => ({ "server.variant": adaptGroup7(raw["server.variant"]), integrations: adaptGroup8(raw["server.integration"]), credentials: adaptGroup9(raw["server.credential"]), - permissions: adaptGroup10(raw["server.permission"]), - files: adaptGroup11(raw["server.fs"]), - commands: adaptGroup12(raw["server.command"]), - skills: adaptGroup13(raw["server.skill"]), - events: adaptGroup14(raw["server.event"]), - ptys: adaptGroup15(raw["server.pty"]), - questions: adaptGroup16(raw["server.question"]), - browserRequests: adaptGroup17(raw["server.browser"]), - references: adaptGroup18(raw["server.reference"]), - projectCopies: adaptGroup19(raw["server.projectCopy"]), - "server.chat": adaptGroup20(raw["server.chat"]), + privacy: adaptGroup10(raw["server.privacy"]), + permissions: adaptGroup11(raw["server.permission"]), + files: adaptGroup12(raw["server.fs"]), + commands: adaptGroup13(raw["server.command"]), + skills: adaptGroup14(raw["server.skill"]), + events: adaptGroup15(raw["server.event"]), + ptys: adaptGroup16(raw["server.pty"]), + questions: adaptGroup17(raw["server.question"]), + browserRequests: adaptGroup18(raw["server.browser"]), + references: adaptGroup19(raw["server.reference"]), + projectCopies: adaptGroup20(raw["server.projectCopy"]), + "server.chat": adaptGroup21(raw["server.chat"]), }) export const make = (options?: { readonly baseUrl?: URL | string }) => diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 860f1fa632..2d5a8739ef 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -67,6 +67,10 @@ import type { CredentialsUpdateOutput, CredentialsRemoveInput, CredentialsRemoveOutput, + PrivacyGetInput, + PrivacyGetOutput, + PrivacySetInput, + PrivacySetOutput, PermissionsListRequestsInput, PermissionsListRequestsOutput, PermissionsListSavedInput, @@ -703,6 +707,33 @@ export function make(options: ClientOptions) { requestOptions, ), }, + privacy: { + get: (input?: PrivacyGetInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/privacy`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + set: (input: PrivacySetInput, requestOptions?: RequestOptions) => + request( + { + method: "PUT", + path: `/api/privacy`, + query: { location: input["location"] }, + body: { mode: input["mode"], model: input["model"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, permissions: { listRequests: (input?: PermissionsListRequestsInput, requestOptions?: RequestOptions) => request( diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 09fcd1fc85..7bd3d4c9ca 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -2372,6 +2372,24 @@ export type CredentialsRemoveInput = { export type CredentialsRemoveOutput = void +export type PrivacyGetInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type PrivacyGetOutput = { readonly mode: "off" | "regex" | "model"; readonly model?: string | undefined } + +export type PrivacySetInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly mode: { readonly mode: "off" | "regex" | "model"; readonly model?: string | undefined }["mode"] + readonly model?: { readonly mode: "off" | "regex" | "model"; readonly model?: string | undefined }["model"] +} + +export type PrivacySetOutput = { readonly mode: "off" | "regex" | "model"; readonly model?: string | undefined } + export type PermissionsListRequestsInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 5227874d53..c6e80cb981 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -13,6 +13,7 @@ import { AbsolutePath } from "./schema" import { ConfigAgent } from "./config/agent" import { ConfigAttachments } from "./config/attachments" import { ConfigCompaction } from "./config/compaction" +import { ConfigPrivacy } from "./config/privacy" import { ConfigCommand } from "./config/command" import { ConfigExperimental } from "./config/experimental" import { ConfigFormatter } from "./config/formatter" @@ -87,6 +88,9 @@ export class Info extends Schema.Class("Config.Info")({ compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({ description: "Conversation compaction behavior", }), + privacy: ConfigPrivacy.Info.pipe(Schema.optional).annotate({ + description: "What personal data may leave this machine inside a model request", + }), skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({ description: "Additional paths or URLs to discover skills from", }), diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index 765992a765..39b331aed0 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -8,6 +8,7 @@ import { AbsolutePath } from "../../schema" import { SkillV2 } from "../../skill" import { Global } from "../../global" import { Location } from "../../location" +import { CONSOLE_SKILLS_URL } from "../../plugin/provider/redrob-constants" export const Plugin = define({ id: "config-skill", @@ -19,7 +20,10 @@ export const Plugin = define({ Effect.fn(function* (draft) { const entries = yield* config.entries() const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) - const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) + const items = [ + CONSOLE_SKILLS_URL, + ...entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])), + ] for (const directory of directories) { draft.source( SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), diff --git a/packages/core/src/config/privacy.ts b/packages/core/src/config/privacy.ts new file mode 100644 index 0000000000..df3cffcbb1 --- /dev/null +++ b/packages/core/src/config/privacy.ts @@ -0,0 +1,27 @@ +export * as ConfigPrivacy from "./privacy" + +import { Schema } from "effect" + +/** + * PII masking: what leaves this machine inside a model request. + * + * off nothing is touched (the default -- the user turns it on). + * regex emails, phone numbers, card numbers and Korean resident numbers are replaced by + * placeholders before any provider sees them, and put back in the answer. + * model regex, plus a LOCAL model (one the engine already serves, e.g. an Ollama model) names + * people, organisations and places in the new text so those are replaced too. + * + * The engine does this in one place for every provider and every app that runs on it, so an app + * only needs a switch, not its own masker. + */ +export const Mode = Schema.Literals(["off", "regex", "model"]) +export type Mode = Schema.Schema.Type + +export class Info extends Schema.Class("ConfigV2.Privacy")({ + mask_pii: Mode.pipe(Schema.optional).annotate({ + description: "Replace personal data with placeholders before a request leaves this machine", + }), + model: Schema.String.pipe(Schema.optional).annotate({ + description: "For mask_pii = model: the LOCAL model that names people, organisations and places, as provider/model", + }), +}) {} diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 89b4dd6219..22b1a14283 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -30,6 +30,7 @@ import { SessionRunnerModel } from "./session/runner/model" import { SessionTodo } from "./session/todo" import { SkillV2 } from "./skill" import { SkillGuidance } from "./skill/guidance" +import { Privacy } from "./privacy/service" import { Snapshot } from "./snapshot" import { SystemContextBuiltIns } from "./system-context/builtins" import { SystemContextRegistry } from "./system-context/registry" @@ -76,6 +77,7 @@ export const locationServices = LayerNode.group([ ReadToolFileSystem.node, BuiltInTools.node, SessionRunnerModel.node, + Privacy.node, Snapshot.node, SessionRunnerLLM.node, ]) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 8a1f3437d3..b73d189695 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -211,7 +211,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int skill.transform((draft) => callback({ source: (source) => draft.source(Schema.decodeUnknownSync(SkillV2.Source)(source)), - list: draft.list, + // The plugin API is typed from the generated SDK, which has no readonly arrays. + list: () => mutable(draft.list()), }), ), }, diff --git a/packages/core/src/plugin/provider/redrob-constants.ts b/packages/core/src/plugin/provider/redrob-constants.ts index e4fa82e0ec..52a7118972 100644 --- a/packages/core/src/plugin/provider/redrob-constants.ts +++ b/packages/core/src/plugin/provider/redrob-constants.ts @@ -21,11 +21,14 @@ import { Flag } from "../../flag/flag" // credentials come from: a local console still wants a key it recognises. Importing Flag is safe from // this leaf module because `flag/flag.ts` imports only `effect` -- the cycle this file avoids is // through the plugin and event modules, which Flag does not touch. -export const CONSOLE_URL = (Flag.REDROB_CONSOLE_URL ?? "https://console.redrob.ai/api/backend/v1").replace( - /\/+$/, - "", -) +export const CONSOLE_URL = (Flag.REDROB_CONSOLE_URL ?? "https://console.redrob.ai/api/backend/v1").replace(/\/+$/, "") export const CONSOLE_PACKAGE = "@ai-sdk/openai-compatible" + +/** + * The Console's skill library (`/skills/index.json` and one directory per skill). Every engine pulls + * it, so every Redrob app that runs on the engine has the same skills; `config.skills` adds more. + */ +export const CONSOLE_SKILLS_URL = new URL("/skills/", CONSOLE_URL).href // Every served console model publishes `capabilities.maxContextTokens: 1_000_000`. Held as one // constant because the value is uniform across the six ids, and used as the no-key fallback // context window. A zero here is not cosmetic: `usable()` in packages/redrob/src/session/overflow.ts diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 6d23dabcf7..6bd98a479b 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -4,13 +4,15 @@ export * as SkillPlugin from "./skill" import { define } from "./internal" import { Effect } from "effect" -import { AbsolutePath } from "../schema" +import { AbsolutePath, type DeepMutable } from "../schema" import { SkillV2 } from "../skill" import customizeRedrobContent from "./skill/customize-redrob.md" with { type: "text" } import documentToolchainContent from "./skill/document-toolchain.md" with { type: "text" } import pageControlContent from "./skill/page-control.md" with { type: "text" } import skillWriterContent from "./skill/skill-writer.md" with { type: "text" } +const mutable = (value: T) => value as DeepMutable + export const CustomizeRedrobContent = customizeRedrobContent /** K-3's skill document. Exported so a test can assert its claims against the real tool tree. */ @@ -62,16 +64,7 @@ export const BuiltinSkills: ReadonlyArray = [ "Use when the task is about the page the user is looking at: reading its text, finding nodes by selector, filling a field, clicking, or navigating. Covers the six `page` calls in code mode, what a refusal means, and the round-trip cost. Do not use for fetching a URL the user is not on, which is a plain HTTP request.", location: AbsolutePath.make("/builtin/page-control.md"), autoInject: SkillV2.AutoInject.make({ - keywords: [ - "this page", - "current page", - "on screen", - "the tab", - "click the", - "fill in", - "selector", - "scrape", - ], + keywords: ["this page", "current page", "on screen", "the tab", "click the", "fill in", "selector", "scrape"], }), content: PageControlContent, }), @@ -91,7 +84,8 @@ export const Plugin = define({ effect: Effect.fn(function* (ctx) { yield* ctx.skill.transform((draft) => { for (const skill of BuiltinSkills) { - draft.source(SkillV2.EmbeddedSource.make({ type: "embedded", skill })) + // The plugin API is typed from the generated SDK, which has no readonly arrays. + draft.source(mutable(SkillV2.EmbeddedSource.make({ type: "embedded", skill }))) } }) }), diff --git a/packages/core/src/privacy/mask.ts b/packages/core/src/privacy/mask.ts new file mode 100644 index 0000000000..c384ac2df0 --- /dev/null +++ b/packages/core/src/privacy/mask.ts @@ -0,0 +1,244 @@ +export * as Mask from "./mask" + +import type { ContentPart, LLMEvent, LLMRequest, Message } from "@redrob-code/llm" +import type { ConfigPrivacy } from "../config/privacy" + +/** + * Replaces personal data in a request with placeholders, and puts it back in the answer. + * + * One Vault per session. A value gets one placeholder for the life of the process, so the model sees + * the same `[PERSON_1]` in turn one and turn five and can refer back to it; the real value never goes + * out and comes back in every text delta and every tool-call input the model produces, so tools run + * on the real thing and the person reads the real thing. + * + * ponytail: the vault lives in memory. After a restart an old value gets a fresh index, which costs + * the model one cross-reference and nothing else. Persist it if that ever matters. + */ + +export type Kind = "EMAIL" | "PHONE" | "CARD" | "RRN" | "PERSON" | "ORG" | "LOC" + +/** A found span: what text, and what kind of thing it is. */ +export type Entity = { readonly value: string; readonly kind: Kind } + +/* ------------------------------------------------------------------ detectors (regex tier) */ + +const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g +/* +82 10-1234-5678, 010-1234-5678, (02) 123-4567, +1 415 555 0100 -- 9 to 15 digits with separators. */ +const PHONE = /(? { + let sum = 0 + let double = false + for (let index = digits.length - 1; index >= 0; index--) { + let digit = Number(digits[index]) + if (double) { + digit *= 2 + if (digit > 9) digit -= 9 + } + sum += digit + double = !double + } + return sum % 10 === 0 +} + +/** The regex tier: what can be found without a model. Order matters -- RRN before PHONE, so a resident number is not read as a phone number. */ +export const detect = (text: string): Entity[] => { + const found: Entity[] = [] + for (const match of text.matchAll(EMAIL)) found.push({ value: match[0], kind: "EMAIL" }) + for (const match of text.matchAll(RRN)) found.push({ value: match[0], kind: "RRN" }) + for (const match of text.matchAll(CARD)) { + if (luhn(match[0].replace(/\D/g, ""))) found.push({ value: match[0], kind: "CARD" }) + } + for (const match of text.matchAll(PHONE)) found.push({ value: match[0], kind: "PHONE" }) + return found +} + +/* ------------------------------------------------------------------ vault */ + +const PLACEHOLDER = /\[(EMAIL|PHONE|CARD|RRN|PERSON|ORG|LOC)_(\d+)\]/g +/* A placeholder cut off by a chunk boundary: `[`, `[PER`, `[PERSON_`, `[PERSON_1`. */ +const PARTIAL = /\[(?:[A-Z]{0,6}(?:_\d*)?)?$/ + +const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + +export class Vault { + private readonly forward = new Map() + private readonly backward = new Map() + private readonly counters = new Map() + + /** Teach the vault a value; returns its placeholder (the same one every time). */ + add(entity: Entity): string { + const known = this.forward.get(entity.value) + if (known) return known + const next = (this.counters.get(entity.kind) ?? 0) + 1 + this.counters.set(entity.kind, next) + const placeholder = `[${entity.kind}_${next}]` + this.forward.set(entity.value, placeholder) + this.backward.set(placeholder, entity.value) + return placeholder + } + + get size() { + return this.forward.size + } + + private readonly scanned = new Set() + + /** The texts the model tier has not read yet; marks them read. Keeps the local call to the new turn, not the whole history. */ + fresh(texts: readonly string[]): string[] { + const result = texts.filter((text) => !this.scanned.has(text)) + for (const text of result) this.scanned.add(text) + return result + } + + /** Replace every known value in `text` by its placeholder. Longest value first, so a name inside an email is not half-replaced. */ + mask(text: string): string { + if (this.forward.size === 0) return text + const values = [...this.forward.keys()].sort((a, b) => b.length - a.length) + return text.replace(new RegExp(values.map(escape).join("|"), "g"), (value) => this.forward.get(value) ?? value) + } + + /** Detect with the regex tier, teach, then mask. */ + maskText(text: string): string { + for (const entity of detect(text)) this.add(entity) + return this.mask(text) + } + + unmask(text: string): string { + if (this.backward.size === 0) return text + return text.replace(PLACEHOLDER, (placeholder) => this.backward.get(placeholder) ?? placeholder) + } + + /** + * Unmask a stream of text chunks. A placeholder can be split across two deltas (`[PERSON_` + + * `1]`), so a trailing partial is held back and prepended to the next chunk; `flush` returns it + * when the block ends. + */ + unmasker() { + let carry = "" + return { + push: (chunk: string): string => { + const text = carry + chunk + const partial = PARTIAL.exec(text) + carry = partial ? partial[0] : "" + return this.unmask(partial ? text.slice(0, partial.index) : text) + }, + flush: (): string => { + const rest = carry + carry = "" + return rest + }, + } + } + + /** Walk any JSON-ish value and unmask every string in it (tool-call inputs). */ + unmaskDeep(value: T): T { + if (typeof value === "string") return this.unmask(value) as T + if (Array.isArray(value)) return value.map((item) => this.unmaskDeep(item)) as T + if (value && typeof value === "object") + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, this.unmaskDeep(item)])) as T + return value + } +} + +/* ------------------------------------------------------------------ request / event mapping */ + +const maskPart = (vault: Vault, part: ContentPart): ContentPart => { + if (part.type === "text") return { ...part, text: vault.maskText(part.text) } + if (part.type === "tool-result" && part.result.type === "text" && typeof part.result.value === "string") + return { ...part, result: { ...part.result, value: vault.maskText(part.result.value) } } + /* ponytail: json tool results and media are passed through. Mask them when a tool is found to leak. */ + return part +} + +/** The request as the provider will see it. The system prompt is ours and is left alone. */ +export const maskRequest = (vault: Vault, request: LLMRequest): LLMRequest => { + const messages: Message[] = request.messages.map((message) => ({ + ...message, + content: message.content.map((part) => maskPart(vault, part)), + })) + return { ...request, messages } +} + +/** The texts of a request a model-tier detector should read: user and tool text, never the system prompt. */ +export const readableTexts = (request: LLMRequest): string[] => + request.messages + .flatMap((message) => message.content) + .flatMap((part) => { + if (part.type === "text") return [part.text] + if (part.type === "tool-result" && part.result.type === "text" && typeof part.result.value === "string") + return [part.result.value] + return [] + }) + +/** + * Put real values back into what the model produced. Text deltas go through a per-block unmasker; + * tool-call inputs are unmasked whole, because the tool is about to run on them. + */ +export const unmaskEvents = (vault: Vault) => { + const blocks = new Map>() + return (event: LLMEvent): LLMEvent[] => { + if (event.type === "text-delta") { + const block = blocks.get(event.id) ?? vault.unmasker() + blocks.set(event.id, block) + return [{ ...event, text: block.push(event.text) }] + } + if (event.type === "text-end") { + const rest = blocks.get(event.id)?.flush() ?? "" + blocks.delete(event.id) + return rest ? [{ type: "text-delta", id: event.id, text: vault.unmask(rest) }, event] : [event] + } + if (event.type === "tool-call") return [{ ...event, input: vault.unmaskDeep(event.input) }] + return [event] + } +} + +/* ------------------------------------------------------------------ settings */ + +export type Settings = { readonly mode: ConfigPrivacy.Mode; readonly model?: string } + +export const settings = (entries: readonly { readonly info?: { readonly privacy?: ConfigPrivacy.Info } }[]): Settings => + entries.reduce( + (result, entry) => ({ + mode: entry.info?.privacy?.mask_pii ?? result.mode, + model: entry.info?.privacy?.model ?? result.model, + }), + { mode: "off" }, + ) + +/* ------------------------------------------------------------------ model tier */ + +/** What the local model is asked. JSON only, so the answer parses or is ignored. */ +export const ENTITY_PROMPT = `List every personal name, organisation name and street address or place name that identifies a person in the text below. Answer with JSON only, in this exact shape and nothing else: +{"PERSON":["..."],"ORG":["..."],"LOC":["..."]} +Copy each value exactly as written in the text. Use empty arrays when there is none. + +TEXT: +` + +/** Parse the local model's answer. Anything that is not the asked-for shape is ignored, never trusted. */ +export const parseEntities = (answer: string): Entity[] => { + const start = answer.indexOf("{") + const end = answer.lastIndexOf("}") + if (start < 0 || end <= start) return [] + let parsed: unknown + try { + parsed = JSON.parse(answer.slice(start, end + 1)) + } catch { + return [] + } + if (!parsed || typeof parsed !== "object") return [] + const kinds: Kind[] = ["PERSON", "ORG", "LOC"] + return kinds.flatMap((kind) => { + const values = (parsed as Record)[kind] + return Array.isArray(values) + ? values + .filter((value): value is string => typeof value === "string" && value.trim().length > 1) + .map((value) => ({ value: value.trim(), kind })) + : [] + }) +} diff --git a/packages/core/src/privacy/service.ts b/packages/core/src/privacy/service.ts new file mode 100644 index 0000000000..62b2af4f27 --- /dev/null +++ b/packages/core/src/privacy/service.ts @@ -0,0 +1,67 @@ +export * as Privacy from "./service" + +import fs from "fs/promises" +import path from "path" +import { Context, Effect, Layer, Ref } from "effect" +import { applyEdits, modify } from "jsonc-parser" +import { Config } from "../config" +import { ConfigPrivacy } from "../config/privacy" +import { makeLocationNode } from "../effect/app-node" +import { Global } from "../global" +import { Mask } from "./mask" + +/** + * The privacy switch an app flips: what `mask_pii` is right now, and how to change it. + * + * Config documents are read once when a location opens, so a value written to the file alone would + * take effect on the next restart. This holds the live value too: `set` changes it for the next + * message AND writes it to the user's global `redrob.json`, so it survives a restart and shows up in + * every location on this machine. One engine, one switch, every app that runs on it. + */ +export interface Interface { + readonly get: () => Effect.Effect + readonly set: (settings: Mask.Settings) => Effect.Effect +} + +export class Error extends globalThis.Error { + readonly _tag = "PrivacyWriteError" +} + +export class Service extends Context.Service()("@redrob/v2/Privacy") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const global = yield* Global.Service + const documents = (yield* config.entries()).filter( + (entry): entry is Config.Document => entry.type === "document", + ) + const current = yield* Ref.make(Mask.settings(documents)) + const file = path.join(global.config, "redrob.json") + + const write = (settings: Mask.Settings) => + Effect.tryPromise({ + try: async () => { + const text = await fs.readFile(file, "utf8").catch(() => "{}") + const value: ConfigPrivacy.Info = { mask_pii: settings.mode, model: settings.model } + const edits = modify(text, ["privacy"], value, { formattingOptions: { tabSize: 2, insertSpaces: true } }) + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.writeFile(file, applyEdits(text, edits)) + }, + catch: (cause) => new Error(`Could not write ${file}: ${cause instanceof globalThis.Error ? cause.message : String(cause)}`), + }) + + return Service.of({ + get: () => Ref.get(current), + set: Effect.fn("Privacy.set")(function* (settings) { + const next: Mask.Settings = { mode: settings.mode, model: settings.model?.trim() || undefined } + yield* write(next) + yield* Ref.set(current, next) + return next + }), + }) + }), +) + +export const node = makeLocationNode({ service: Service, layer, deps: () => [Config.node, Global.node] }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 02cfca1759..5cf5be7b34 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -38,6 +38,8 @@ import { toLLMMessages } from "./to-llm-message" import { MAX_STEPS_PROMPT } from "./max-steps" import { Snapshot } from "../../snapshot" import { makeLocationNode } from "../../effect/app-node" +import { Mask } from "../../privacy/mask" +import { Privacy } from "../../privacy/service" import { llmClient } from "../../effect/app-node-platform" /** @@ -90,6 +92,13 @@ import { llmClient } from "../../effect/app-node-platform" * explicit loop starts the next provider turn after local settlement. Configured agent step limits bound the loop. */ +/** + * Which Redrob app is driving this engine (cowork, office, design, code), set by the app on the + * engine process it starts. Sent as `x-redrob-app` so the Console can group AI work by app. It is a + * label and nothing else: an unset value sends no header. + */ +const APP_NAME = process.env.REDROB_APP?.trim() || undefined + const layer = Layer.effect( Service, Effect.gen(function* () { @@ -107,6 +116,55 @@ const layer = Layer.effect( const snapshots = yield* Snapshot.Service const db = (yield* Database.Service).db const compaction = SessionCompaction.make({ events, llm, config: yield* config.entries() }) + const privacyService = yield* Privacy.Service + /** + * PII masking, one vault per session, read from the privacy service on every turn so a switch + * flipped in the app takes effect on the next message. Off by default. + */ + const vaults = new Map() + const vaultFor = (sessionID: SessionSchema.ID) => { + const vault = vaults.get(sessionID) ?? new Mask.Vault() + vaults.set(sessionID, vault) + return vault + } + /** Model tier: ask the configured LOCAL model for names, organisations and places in the text, and teach the vault. */ + const learnEntities = Effect.fn("SessionRunner.learnEntities")(function* ( + session: SessionSchema.Info, + vault: Mask.Vault, + modelRef: string, + text: string, + ) { + const slash = modelRef.indexOf("/") + if (slash <= 0) return + const local = yield* models + .resolve({ + ...session, + model: { + providerID: ProviderV2.ID.make(modelRef.slice(0, slash)), + id: ModelV2.ID.make(modelRef.slice(slash + 1)), + }, + }) + .pipe(Effect.option) + if (Option.isNone(local)) return + const chunks: string[] = [] + yield* llm + .stream( + LLM.request({ + model: local.value, + messages: [Message.user(Mask.ENTITY_PROMPT + text)], + tools: [], + generation: { maxTokens: 512 }, + }), + ) + .pipe( + Stream.runForEach((event) => { + if (LLMEvent.is.textDelta(event)) chunks.push(event.text) + return Effect.void + }), + Effect.catchTag("LLM.Error", () => Effect.void), + ) + for (const entity of Mask.parseEntities(chunks.join(""))) vault.add(entity) + }) const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) { const session = yield* store.get(sessionID) if (!session) return yield* Effect.die(`Session not found: ${sessionID}`) @@ -202,13 +260,16 @@ const layer = Layer.effect( const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id - const request = LLM.request({ + const privacy = yield* privacyService.get() + const vault = privacy.mode === "off" ? undefined : vaultFor(session.id) + const unmasked = LLM.request({ model, http: { headers: { "x-session-affinity": session.id, "X-Session-Id": session.id, ...(session.parentID ? { "x-parent-session-id": session.parentID } : {}), + ...(APP_NAME ? { "x-redrob-app": APP_NAME } : {}), }, }, providerOptions: { openai: { promptCacheKey } }, @@ -219,6 +280,12 @@ const layer = Layer.effect( tools: toolMaterialization?.definitions ?? [], toolChoice: isLastStep ? "none" : undefined, }) + if (vault && privacy.mode === "model" && privacy.model) { + const fresh = vault.fresh(Mask.readableTexts(unmasked)) + if (fresh.length > 0) yield* learnEntities(session, vault, privacy.model, fresh.join("\n")) + } + const request = vault ? Mask.maskRequest(vault, unmasked) : unmasked + const unmaskEvent = vault ? Mask.unmaskEvents(vault) : undefined if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request })) return yield* Effect.die(continueAfterCompaction(currentStep)) const startSnapshot = yield* snapshots.capture() @@ -236,7 +303,11 @@ const layer = Layer.effect( const publish = (event: LLMEvent, outputPaths: ReadonlyArray = []) => withPublication(publisher.publish(event, outputPaths)) let overflowFailure: ProviderErrorEvent | undefined - const providerStream = llm.stream(request).pipe( + const providerStream = ( + unmaskEvent + ? llm.stream(request).pipe(Stream.flatMap((event) => Stream.fromIterable(unmaskEvent(event)))) + : llm.stream(request) + ).pipe( Stream.runForEach((event) => Effect.gen(function* () { if (overflowFailure || publisher.hasProviderError()) return @@ -433,6 +504,7 @@ export const node = makeLocationNode({ SkillGuidance.node, ReferenceGuidance.node, Config.node, + Privacy.node, Snapshot.node, Database.node, ], diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index 3c159c9178..4cfec483d8 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -10,6 +10,7 @@ import { SkillV2 } from "@redrob-code/core/skill" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" import { host } from "../plugin/host" +import { CONSOLE_SKILLS_URL } from "@redrob-code/core/plugin/provider/redrob-constants" const it = testEffect(Layer.empty) const decode = Schema.decodeUnknownSync(Config.Info) @@ -67,6 +68,8 @@ describe("ConfigSkillPlugin.Plugin", () => { type: "directory", path: AbsolutePath.make(path.join("/repo/.opencode", "skills")), }), + // The Console's library comes first, before anything the user configured. + SkillV2.UrlSource.make({ type: "url", url: CONSOLE_SKILLS_URL }), SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }), SkillV2.DirectorySource.make({ type: "directory", diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 76fa346747..e1076dbb01 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -8,7 +8,12 @@ import { ProviderV2 } from "@redrob-code/core/provider" import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@redrob-labs/sdk/v2/types" import { Effect } from "effect" -type Overrides = Partial> +/** + * `skill` is widened because tests hand in the core SkillV2 service directly, whose arrays are + * readonly, while the plugin API is typed from the generated SDK, which has no readonly arrays. + * The real host converts at the boundary (plugin/host.ts `mutable`); here a cast is enough. + */ +type Overrides = Partial> & { skill?: unknown } export function host(overrides: Overrides = {}): PluginContext { return { @@ -45,7 +50,7 @@ export function host(overrides: Overrides = {}): PluginContext { transform: () => Effect.die("unused reference.transform"), reload: () => Effect.die("unused reference.reload"), }, - skill: overrides.skill ?? { + skill: (overrides.skill as PluginContext["skill"] | undefined) ?? { transform: () => Effect.die("unused skill.transform"), reload: () => Effect.die("unused skill.reload"), }, diff --git a/packages/core/test/privacy/mask.test.ts b/packages/core/test/privacy/mask.test.ts new file mode 100644 index 0000000000..aa1b6dc7ce --- /dev/null +++ b/packages/core/test/privacy/mask.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test" +import { Mask } from "@redrob-code/core/privacy/mask" + +describe("privacy mask", () => { + test("regex tier finds email, phone, card and resident number; placeholders are stable", () => { + const vault = new Mask.Vault() + const text = "Mail hong@example.com or call 010-1234-5678. Card 4242 4242 4242 4242, RRN 900101-1234567. Order 12345678901234." + const masked = vault.maskText(text) + expect(masked).toBe("Mail [EMAIL_1] or call [PHONE_1]. Card [CARD_1], RRN [RRN_1]. Order 12345678901234.") + expect(vault.maskText("hong@example.com again")).toBe("[EMAIL_1] again") + expect(vault.unmask(masked)).toBe(text) + }) + + test("a request leaves with placeholders in user text and tool results, system prompt untouched", () => { + const vault = new Mask.Vault() + const request = { + model: {} as never, + system: [{ type: "text" as const, text: "You help hong@example.com" }], + messages: [ + { role: "user" as const, content: [{ type: "text" as const, text: "write to hong@example.com" }] }, + { + role: "tool" as const, + content: [ + { + type: "tool-result" as const, + id: "t1", + name: "read", + result: { type: "text" as const, value: "contact: hong@example.com" }, + }, + ], + }, + ], + tools: [], + } + const masked = Mask.maskRequest(vault, request as never) + expect(JSON.stringify(masked.messages)).not.toContain("hong@example.com") + expect(JSON.stringify(masked.messages)).toContain("[EMAIL_1]") + expect(masked.system[0]!.text).toContain("hong@example.com") + }) + + test("streamed answer is restored even when a placeholder splits across deltas; tool inputs are restored whole", () => { + const vault = new Mask.Vault() + vault.add({ value: "Hong Gildong", kind: "PERSON" }) + const unmask = Mask.unmaskEvents(vault) + const out: string[] = [] + for (const chunk of ["Dear [PER", "SON_", "1], hello [PERSON_1]", " and [PERSON_2]"]) { + for (const event of unmask({ type: "text-delta", id: "b", text: chunk } as never)) + if (event.type === "text-delta") out.push(event.text) + } + for (const event of unmask({ type: "text-end", id: "b" } as never)) if (event.type === "text-delta") out.push(event.text) + expect(out.join("")).toBe("Dear Hong Gildong, hello Hong Gildong and [PERSON_2]") + + const [call] = unmask({ type: "tool-call", id: "c", name: "write", input: { path: "[PERSON_1].md", lines: ["[PERSON_1]"] } } as never) + expect((call as { input: unknown }).input).toEqual({ path: "Hong Gildong.md", lines: ["Hong Gildong"] }) + }) + + test("model answer is parsed strictly; garbage is ignored", () => { + expect(Mask.parseEntities('Sure! {"PERSON":["Hong Gildong"],"ORG":["Redrob"],"LOC":[]} done')).toEqual([ + { value: "Hong Gildong", kind: "PERSON" }, + { value: "Redrob", kind: "ORG" }, + ]) + expect(Mask.parseEntities("no json here")).toEqual([]) + expect(Mask.parseEntities('{"PERSON": 42}')).toEqual([]) + }) + + test("settings default to off and the last document wins", () => { + expect(Mask.settings([]).mode).toBe("off") + expect( + Mask.settings([ + { info: { privacy: { mask_pii: "model", model: "ollama/qwen" } } }, + { info: { privacy: { mask_pii: "regex" } } }, + ]), + ).toEqual({ mode: "regex", model: "ollama/qwen" }) + }) +}) diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index 43b1e267de..13696b5e25 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -22,6 +22,7 @@ import { Authorization } from "./middleware/authorization" import { LocationGroup } from "./groups/location" import { IntegrationGroup } from "./groups/integration" import { CredentialGroup } from "./groups/credential" +import { PrivacyGroup } from "./groups/privacy" import { ProjectCopyGroup } from "./groups/project-copy" import { ChatCompletionGroup } from "./groups/chat-completion" @@ -48,6 +49,7 @@ const makeApiFromGroup = < .add(VariantGroup.middleware(locationMiddleware)) .add(IntegrationGroup.middleware(locationMiddleware)) .add(CredentialGroup.middleware(locationMiddleware)) + .add(PrivacyGroup.middleware(locationMiddleware)) .add(makePermissionGroup(locationMiddleware, sessionLocationMiddleware)) .add(FileSystemGroup.middleware(locationMiddleware)) .add(CommandGroup.middleware(locationMiddleware)) diff --git a/packages/protocol/src/groups/privacy.ts b/packages/protocol/src/groups/privacy.ts new file mode 100644 index 0000000000..39f12f0d5b --- /dev/null +++ b/packages/protocol/src/groups/privacy.ts @@ -0,0 +1,47 @@ +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location" + +/** + * The privacy switch. One value for the whole engine, so an app shows a toggle and nothing more: + * `off` (default), `regex` (emails, phones, cards, resident numbers), `model` (regex plus a local + * model naming people, organisations and places). + */ +export const PrivacySettings = Schema.Struct({ + mode: Schema.Literals(["off", "regex", "model"]), + model: Schema.optional(Schema.String).annotate({ + description: "For mode = model: the LOCAL model that finds names, as provider/model", + }), +}).annotate({ identifier: "PrivacySettings" }) + +export const PrivacyGroup = HttpApiGroup.make("server.privacy") + .add( + HttpApiEndpoint.get("privacy.get", "/api/privacy", { + query: LocationQuery, + success: PrivacySettings, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.privacy.get", + summary: "Read the PII masking setting", + description: "What personal data may leave this machine inside a model request.", + }), + ), + ) + .add( + HttpApiEndpoint.put("privacy.set", "/api/privacy", { + query: LocationQuery, + payload: PrivacySettings, + success: PrivacySettings, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.privacy.set", + summary: "Change the PII masking setting", + description: + "Takes effect on the next message and is written to the user's global redrob.json, so it holds across restarts and apps.", + }), + ), + ) diff --git a/packages/redrob/test/server/httpapi-exercise/index.ts b/packages/redrob/test/server/httpapi-exercise/index.ts index 3c4a84f0ad..4b245ad0c2 100644 --- a/packages/redrob/test/server/httpapi-exercise/index.ts +++ b/packages/redrob/test/server/httpapi-exercise/index.ts @@ -750,6 +750,17 @@ const scenarios: Scenario[] = [ body: { label: "Work" }, })) .status(204, undefined, "status"), + http.protected.get("/api/privacy", "v2.privacy.get").json(200, (body) => { + object(body) + check(body.mode === "off", "masking should be off until someone turns it on") + }), + http.protected + .put("/api/privacy", "v2.privacy.set") + .at((ctx) => ({ path: "/api/privacy", headers: ctx.headers(), body: { mode: "off" } })) + .json(200, (body) => { + object(body) + check(body.mode === "off", "the setting should be echoed back") + }), http.protected.get("/api/command", "v2.command.list").json(200, locationData(array)), http.protected.get("/api/skill", "v2.skill.list").json(200, locationData(array)), http.protected diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index ce3837420c..5bf5bf79a9 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -15,6 +15,8 @@ import type { AuthRemoveResponses, AuthSetErrors, AuthSetResponses, + BrowserRequestRefusal, + BrowserRequestReply, ChatCompletionRequest, CommandListErrors, CommandListResponses, @@ -131,6 +133,7 @@ import type { PermissionRuleset, PermissionV2Reply, PermissionV2Source, + PrivacySettings, ProjectCommands, ProjectCurrentErrors, ProjectCurrentResponses, @@ -264,8 +267,16 @@ import type { TuiShowToastResponses, TuiSubmitPromptErrors, TuiSubmitPromptResponses, + V1BrowserRequestListErrors, + V1BrowserRequestListResponses, V1ChatCompletionsErrors, V1ChatCompletionsResponses, + V1SessionBrowserListErrors, + V1SessionBrowserListResponses, + V1SessionBrowserRefuseErrors, + V1SessionBrowserRefuseResponses, + V1SessionBrowserReplyErrors, + V1SessionBrowserReplyResponses, V2AgentListErrors, V2AgentListResponses, V2CommandListErrors, @@ -308,6 +319,10 @@ import type { V2PermissionSavedListResponses, V2PermissionSavedRemoveErrors, V2PermissionSavedRemoveResponses, + V2PrivacyGetErrors, + V2PrivacyGetResponses, + V2PrivacySetErrors, + V2PrivacySetResponses, V2ProjectCopyCreateErrors, V2ProjectCopyCreateResponses, V2ProjectCopyRefreshErrors, @@ -6377,6 +6392,68 @@ export class Credential extends HeyApiClient { } } +export class Privacy extends HeyApiClient { + /** + * Read the PII masking setting + * + * What personal data may leave this machine inside a model request. + */ + public get( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/privacy", + ...options, + ...params, + }) + } + + /** + * Change the PII masking setting + * + * Takes effect on the next message and is written to the user's global redrob.json, so it holds across restarts and apps. + */ + public set( + parameters: { + location?: { + directory?: string + workspace?: string + } + privacySettings: PrivacySettings + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { key: "privacySettings", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).put({ + url: "/api/privacy", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + export class Request extends HeyApiClient { /** * List pending permission requests @@ -7094,6 +7171,11 @@ export class V2 extends HeyApiClient { return (this._credential ??= new Credential({ client: this.client })) } + private _privacy?: Privacy + get privacy(): Privacy { + return (this._privacy ??= new Privacy({ client: this.client })) + } + private _permission?: Permission3 get permission(): Permission3 { return (this._permission ??= new Permission3({ client: this.client })) @@ -7140,6 +7222,155 @@ export class V2 extends HeyApiClient { } } +export class Request3 extends HeyApiClient { + /** + * List pending browser requests + * + * Retrieve browser actions awaiting a client for a location. A client that reconnects uses this to recover requests published while it was away. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get< + V1BrowserRequestListResponses, + V1BrowserRequestListErrors, + ThrowOnError + >({ + url: "/api/browser/request", + ...options, + ...params, + }) + } +} + +export class Browser extends HeyApiClient { + private _request?: Request3 + get request(): Request3 { + return (this._request ??= new Request3({ client: this.client })) + } +} + +export class Browser2 extends HeyApiClient { + /** + * List session browser requests + * + * Retrieve browser actions awaiting a client, owned by a session. + */ + public list( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).get< + V1SessionBrowserListResponses, + V1SessionBrowserListErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/browser", + ...options, + ...params, + }) + } + + /** + * Answer a pending browser request + * + * Settle a browser action with the value it produced. The value is typed per action, so answering with the wrong shape is reported to the engine's caller rather than read as a fact about the page. + */ + public reply( + parameters: { + sessionID: string + requestID: string + browserRequestReply: BrowserRequestReply + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "requestID" }, + { key: "browserRequestReply", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V1SessionBrowserReplyResponses, + V1SessionBrowserReplyErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/browser/{requestID}/reply", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Refuse a pending browser request + * + * Report that the action could not be performed, with the reason. Refusing is faster and more honest than letting the request expire on the engine's deadline. + */ + public refuse( + parameters: { + sessionID: string + requestID: string + browserRequestRefusal: BrowserRequestRefusal + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "requestID" }, + { key: "browserRequestRefusal", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V1SessionBrowserRefuseResponses, + V1SessionBrowserRefuseErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/browser/{requestID}/refuse", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Session4 extends HeyApiClient { + private _browser?: Browser2 + get browser(): Browser2 { + return (this._browser ??= new Browser2({ client: this.client })) + } +} + export class Chat extends HeyApiClient { /** * Create a chat completion @@ -7167,6 +7398,16 @@ export class Chat extends HeyApiClient { } export class V1 extends HeyApiClient { + private _browser?: Browser + get browser(): Browser { + return (this._browser ??= new Browser({ client: this.client })) + } + + private _session?: Session4 + get session(): Session4 { + return (this._session ??= new Session4({ client: this.client })) + } + private _chat?: Chat get chat(): Chat { return (this._chat ??= new Chat({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 11e8c0238a..96f3a1a7e2 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -67,6 +67,9 @@ export type Event = | EventQuestionV2Asked | EventQuestionV2Replied | EventQuestionV2Rejected + | EventBrowserRequestAsked + | EventBrowserRequestAnswered + | EventBrowserRequestRefused | EventTodoUpdated | EventLspUpdated | EventPermissionAsked @@ -1401,6 +1404,32 @@ export type GlobalEvent = { requestID: string } } + | { + id: string + type: "browser.request.asked" + properties: { + id: string + sessionID: string + command: BrowserRequestCommand + } + } + | { + id: string + type: "browser.request.answered" + properties: { + sessionID: string + requestID: string + } + } + | { + id: string + type: "browser.request.refused" + properties: { + sessionID: string + requestID: string + reason: string + } + } | { id: string type: "todo.updated" @@ -2839,6 +2868,11 @@ export type ProviderNotFoundError = { message: string } +export type PrivacySettings = { + mode: "off" | "regex" | "model" + model?: string +} + export type OutputFormat1 = | { type: "text" @@ -2967,6 +3001,9 @@ export type V2Event = | QuestionV2Asked | QuestionV2Replied | QuestionV2Rejected + | BrowserRequestAsked + | BrowserRequestAnswered + | BrowserRequestRefused | TodoUpdated | LspUpdated | PermissionAsked @@ -3001,6 +3038,12 @@ export type ForbiddenError = { message: string } +export type BrowserRequestNotFoundError = { + _tag: "BrowserRequestNotFoundError" + requestID: string + message: string +} + export type ProjectCopyError = { name: "ProjectCopyError" data: { @@ -3296,6 +3339,33 @@ export type QuestionV2Tool = { export type QuestionV2Answer = Array +export type BrowserRequestCommand = + | { + action: "page.url" + } + | { + action: "page.text" + } + | { + action: "page.query" + selector: string + limit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + | { + action: "page.click" + selector: string + } + | { + action: "page.type" + selector: string + text: string + submit?: boolean + } + | { + action: "page.navigate" + url: string + } + export type ProjectVcs = "git" export type ProjectIcon = { @@ -3978,6 +4048,11 @@ export type ConfigV2ExperimentalPolicy = { resource: string } +export type SkillV2AutoInject = { + keywords?: Array + url?: Array +} + export type ProjectDirectories = Array<{ directory: string strategy?: string @@ -5196,6 +5271,8 @@ export type SkillV2Info = { name: string description?: string slash?: boolean + icon?: string + autoInject?: SkillV2AutoInject location: string content: string } @@ -5837,6 +5914,89 @@ export type QuestionV2Rejected = { } } +export type BrowserRequestCommand1 = + | { + action: "page.url" + } + | { + action: "page.text" + } + | { + action: "page.query" + selector: string + limit?: number | "NaN" | "Infinity" | "-Infinity" + } + | { + action: "page.click" + selector: string + } + | { + action: "page.type" + selector: string + text: string + submit?: boolean + } + | { + action: "page.navigate" + url: string + } + +export type BrowserRequestAsked = { + id: string + metadata?: { + [key: string]: unknown + } + type: "browser.request.asked" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + sessionID: string + command: BrowserRequestCommand1 + } +} + +export type BrowserRequestAnswered = { + id: string + metadata?: { + [key: string]: unknown + } + type: "browser.request.answered" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + } +} + +export type BrowserRequestRefused = { + id: string + metadata?: { + [key: string]: unknown + } + type: "browser.request.refused" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + reason: string + } +} + export type TodoUpdated = { id: string metadata?: { @@ -6301,6 +6461,44 @@ export type QuestionV2Reply = { answers: Array } +export type BrowserRequestRequest = { + id: string + sessionID: string + command: BrowserRequestCommand +} + +export type BrowserRequestNode = { + selector: string + text: string + attributes: { + [key: string]: string + } +} + +export type BrowserRequestValue = + | { + type: "string" + value: string + } + | { + type: "nodes" + value: Array + } + | { + type: "void" + } + +export type BrowserRequestReply = { + value: BrowserRequestValue +} + +export type BrowserRequestRefusal = { + /** + * Why the action could not be performed, in one sentence. + */ + reason: string +} + export type ReferenceLocalSource = { type: "local" path: string @@ -7018,6 +7216,62 @@ export type EventQuestionV2Rejected = { } } +export type BrowserRequestCommand2 = + | { + action: "page.url" + } + | { + action: "page.text" + } + | { + action: "page.query" + selector: string + limit?: number | "NaN" | "Infinity" | "-Infinity" + } + | { + action: "page.click" + selector: string + } + | { + action: "page.type" + selector: string + text: string + submit?: boolean + } + | { + action: "page.navigate" + url: string + } + +export type EventBrowserRequestAsked = { + id: string + type: "browser.request.asked" + properties: { + id: string + sessionID: string + command: BrowserRequestCommand2 + } +} + +export type EventBrowserRequestAnswered = { + id: string + type: "browser.request.answered" + properties: { + sessionID: string + requestID: string + } +} + +export type EventBrowserRequestRefused = { + id: string + type: "browser.request.refused" + properties: { + sessionID: string + requestID: string + reason: string + } +} + export type EventTodoUpdated = { id: string type: "todo.updated" @@ -8546,6 +8800,7 @@ export type AppSkillsResponses = { description?: string location: string content: string + autoInject?: SkillV2AutoInject }> } @@ -12765,6 +13020,74 @@ export type V2CredentialUpdateResponses = { export type V2CredentialUpdateResponse = V2CredentialUpdateResponses[keyof V2CredentialUpdateResponses] +export type V2PrivacyGetData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/privacy" +} + +export type V2PrivacyGetErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2PrivacyGetError = V2PrivacyGetErrors[keyof V2PrivacyGetErrors] + +export type V2PrivacyGetResponses = { + /** + * PrivacySettings + */ + 200: PrivacySettings +} + +export type V2PrivacyGetResponse = V2PrivacyGetResponses[keyof V2PrivacyGetResponses] + +export type V2PrivacySetData = { + body: PrivacySettings + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/privacy" +} + +export type V2PrivacySetErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2PrivacySetError = V2PrivacySetErrors[keyof V2PrivacySetErrors] + +export type V2PrivacySetResponses = { + /** + * PrivacySettings + */ + 200: PrivacySettings +} + +export type V2PrivacySetResponse = V2PrivacySetResponses[keyof V2PrivacySetResponses] + export type V2PermissionRequestListData = { body?: never path?: never @@ -13698,6 +14021,152 @@ export type V2SessionQuestionRejectResponses = { export type V2SessionQuestionRejectResponse = V2SessionQuestionRejectResponses[keyof V2SessionQuestionRejectResponses] +export type V1BrowserRequestListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/browser/request" +} + +export type V1BrowserRequestListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V1BrowserRequestListError = V1BrowserRequestListErrors[keyof V1BrowserRequestListErrors] + +export type V1BrowserRequestListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V1BrowserRequestListResponse = V1BrowserRequestListResponses[keyof V1BrowserRequestListResponses] + +export type V1SessionBrowserListData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/browser" +} + +export type V1SessionBrowserListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V1SessionBrowserListError = V1SessionBrowserListErrors[keyof V1SessionBrowserListErrors] + +export type V1SessionBrowserListResponses = { + /** + * Success + */ + 200: { + data: Array + } +} + +export type V1SessionBrowserListResponse = V1SessionBrowserListResponses[keyof V1SessionBrowserListResponses] + +export type V1SessionBrowserReplyData = { + body: BrowserRequestReply + path: { + sessionID: string + requestID: string + } + query?: never + url: "/api/session/{sessionID}/browser/{requestID}/reply" +} + +export type V1SessionBrowserReplyErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError | BrowserRequestNotFoundError + */ + 404: BrowserRequestNotFoundError | SessionNotFoundError +} + +export type V1SessionBrowserReplyError = V1SessionBrowserReplyErrors[keyof V1SessionBrowserReplyErrors] + +export type V1SessionBrowserReplyResponses = { + /** + * + */ + 204: void +} + +export type V1SessionBrowserReplyResponse = V1SessionBrowserReplyResponses[keyof V1SessionBrowserReplyResponses] + +export type V1SessionBrowserRefuseData = { + body: BrowserRequestRefusal + path: { + sessionID: string + requestID: string + } + query?: never + url: "/api/session/{sessionID}/browser/{requestID}/refuse" +} + +export type V1SessionBrowserRefuseErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError | BrowserRequestNotFoundError + */ + 404: BrowserRequestNotFoundError | SessionNotFoundError +} + +export type V1SessionBrowserRefuseError = V1SessionBrowserRefuseErrors[keyof V1SessionBrowserRefuseErrors] + +export type V1SessionBrowserRefuseResponses = { + /** + * + */ + 204: void +} + +export type V1SessionBrowserRefuseResponse = V1SessionBrowserRefuseResponses[keyof V1SessionBrowserRefuseResponses] + export type V2ReferenceListData = { body?: never path?: never diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 5a1dc10f79..143a53d92b 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -18,6 +18,7 @@ import { ReferenceHandler } from "./handlers/reference" import { LocationHandler } from "./handlers/location" import { IntegrationHandler } from "./handlers/integration" import { CredentialHandler } from "./handlers/credential" +import { PrivacyHandler } from "./handlers/privacy" import { ProjectCopyHandler } from "./handlers/project-copy" import { ChatCompletionHandler } from "./handlers/chat-completion" @@ -32,6 +33,7 @@ export const handlers = Layer.mergeAll( VariantHandler, IntegrationHandler, CredentialHandler, + PrivacyHandler, PermissionHandler, FileSystemHandler, CommandHandler, diff --git a/packages/server/src/handlers/privacy.ts b/packages/server/src/handlers/privacy.ts new file mode 100644 index 0000000000..790b67b3d4 --- /dev/null +++ b/packages/server/src/handlers/privacy.ts @@ -0,0 +1,20 @@ +import { Privacy } from "@redrob-code/core/privacy/service" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" + +export const PrivacyHandler = HttpApiBuilder.group(Api, "server.privacy", (handlers) => + handlers + .handle( + "privacy.get", + Effect.fn(function* () { + return yield* (yield* Privacy.Service).get() + }), + ) + .handle( + "privacy.set", + Effect.fn(function* (ctx) { + return yield* (yield* Privacy.Service).set(ctx.payload).pipe(Effect.orDie) + }), + ), +)