From a5b20f973fb575ba25256c1b3cb13b03bea96fa1 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 31 Jul 2025 12:26:34 -0400 Subject: [PATCH] wip: refactor permissions --- opencode.json | 4 + packages/opencode/src/id/id.ts | 1 + packages/opencode/src/permission/index.ts | 43 +++--- packages/opencode/src/tool/bash.ts | 5 +- packages/opencode/src/tool/edit.ts | 8 +- packages/opencode/src/tool/write.ts | 4 +- packages/sdk/go/.stats.yml | 8 +- packages/sdk/go/api.md | 12 ++ packages/sdk/go/event.go | 59 +------- packages/sdk/go/session.go | 43 +++++- packages/sdk/go/session_test.go | 26 ++++ packages/sdk/go/sessionpermission.go | 130 ++++++++++++++++++ packages/sdk/go/sessionpermission_test.go | 43 ++++++ packages/tui/go.mod | 2 +- .../tui/internal/components/chat/messages.go | 4 +- 15 files changed, 294 insertions(+), 98 deletions(-) create mode 100644 packages/sdk/go/sessionpermission.go create mode 100644 packages/sdk/go/sessionpermission_test.go diff --git a/opencode.json b/opencode.json index 8efc57a7..4789660d 100644 --- a/opencode.json +++ b/opencode.json @@ -28,5 +28,9 @@ "type": "local", "command": ["opencode", "x", "@h1deya/mcp-server-weather"] } + }, + "permission": { + "edit": "ask", + "bash": "ask" } } diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 6c1edd50..4e3ba9d4 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -5,6 +5,7 @@ export namespace Identifier { const prefixes = { session: "ses", message: "msg", + permission: "per", user: "usr", part: "prt", } as const diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index e7b6854f..a4239651 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -3,6 +3,7 @@ import { z } from "zod" import { Bus } from "../bus" import { Log } from "../util/log" import { Installation } from "../installation" +import { Identifier } from "../id/id" export namespace Permission { const log = Log.create({ service: "permission" }) @@ -10,9 +11,11 @@ export namespace Permission { export const Info = z .object({ id: z.string(), + type: z.string(), + pattern: z.string().optional(), sessionID: z.string(), messageID: z.string(), - toolCallID: z.string().optional(), + callID: z.string().optional(), title: z.string(), metadata: z.record(z.any()), time: z.object({ @@ -55,18 +58,19 @@ export namespace Permission { async (state) => { for (const pending of Object.values(state.pending)) { for (const item of Object.values(pending)) { - item.reject(new RejectedError(item.info.sessionID, item.info.id, item.info.toolCallID)) + item.reject(new RejectedError(item.info.sessionID, item.info.id, item.info.callID)) } } }, ) export function ask(input: { - id: Info["id"] + type: Info["type"] + title: Info["title"] + pattern?: Info["pattern"] + callID?: Info["callID"] sessionID: Info["sessionID"] messageID: Info["messageID"] - toolCallID?: Info["toolCallID"] - title: Info["title"] metadata: Info["metadata"] }) { // TODO: dax, remove this when you're happy with permissions @@ -75,24 +79,16 @@ export namespace Permission { const { pending, approved } = state() log.info("asking", { sessionID: input.sessionID, - permissionID: input.id, messageID: input.messageID, - toolCallID: input.toolCallID, + toolCallID: input.callID, }) - if (approved[input.sessionID]?.[input.id]) { - log.info("previously approved", { - sessionID: input.sessionID, - permissionID: input.id, - messageID: input.messageID, - toolCallID: input.toolCallID, - }) - return - } + if (approved[input.sessionID]?.[input.pattern ?? input.type]) return const info: Info = { - id: input.id, + id: Identifier.ascending("permission"), + type: input.type, sessionID: input.sessionID, messageID: input.messageID, - toolCallID: input.toolCallID, + callID: input.callID, title: input.title, metadata: input.metadata, time: { @@ -101,18 +97,11 @@ export namespace Permission { } pending[input.sessionID] = pending[input.sessionID] || {} return new Promise((resolve, reject) => { - pending[input.sessionID][input.id] = { + pending[input.sessionID][info.id] = { info, resolve, reject, } - // setTimeout(() => { - // respond({ - // sessionID: input.sessionID, - // permissionID: input.id, - // response: "always", - // }) - // }, 1000) Bus.publish(Event.Updated, info) }) } @@ -127,7 +116,7 @@ export namespace Permission { if (!match) return delete pending[input.sessionID][input.permissionID] if (input.response === "reject") { - match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.toolCallID)) + match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID)) return } match.resolve() diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 3032c52c..ea845e4d 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -108,10 +108,11 @@ export const BashTool = Tool.define("bash", { const cfg = await Config.get() if (cfg.permission?.bash === "ask") await Permission.ask({ - id: "bash", + type: "bash", + pattern: params.command.split(" ").slice(0, 2).join(" ").trim(), sessionID: ctx.sessionID, messageID: ctx.messageID, - toolCallID: ctx.toolCallID, + callID: ctx.toolCallID, title: "Run this command: " + params.command, metadata: { command: params.command, diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index c8038a89..588522cb 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -50,10 +50,10 @@ export const EditTool = Tool.define("edit", { diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) if (cfg.permission?.edit === "ask") { await Permission.ask({ - id: "edit", + type: "edit", sessionID: ctx.sessionID, messageID: ctx.messageID, - toolCallID: ctx.toolCallID, + callID: ctx.toolCallID, title: "Edit this file: " + filePath, metadata: { filePath, @@ -79,10 +79,10 @@ export const EditTool = Tool.define("edit", { diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) if (cfg.permission?.edit === "ask") { await Permission.ask({ - id: "edit", + type: "edit", sessionID: ctx.sessionID, messageID: ctx.messageID, - toolCallID: ctx.toolCallID, + callID: ctx.toolCallID, title: "Edit this file: " + filePath, metadata: { filePath, diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 3fde2524..279b7fa6 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -31,10 +31,10 @@ export const WriteTool = Tool.define("write", { const cfg = await Config.get() if (cfg.permission?.edit === "ask") await Permission.ask({ - id: "write", + type: "write", sessionID: ctx.sessionID, messageID: ctx.messageID, - toolCallID: ctx.toolCallID, + callID: ctx.toolCallID, title: exists ? "Overwrite this file: " + filepath : "Create new file: " + filepath, metadata: { filePath: filepath, diff --git a/packages/sdk/go/.stats.yml b/packages/sdk/go/.stats.yml index a42ec8ee..471c9026 100644 --- a/packages/sdk/go/.stats.yml +++ b/packages/sdk/go/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 26 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-5bf6a39123d248d306490c1dee61b46ba113ea2c415a4de1a631c76462769c49.yml -openapi_spec_hash: 3c5b25f121429281275ffd70c9d5cfe4 -config_hash: 1ae82c93499b9f0b9ba828b8919f9cb3 +configured_endpoints: 28 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-3fa00e84a92784c0e12cf47a49cf5ac4eb5556b5b3ad8769ad7b4e7e1bf1b01a.yml +openapi_spec_hash: 5f98ce812d7feb00e6c2eb7a15dd8887 +config_hash: 7707d73ebbd7ad7042ab70466b39348d diff --git a/packages/sdk/go/api.md b/packages/sdk/go/api.md index fb3db9c5..0291c776 100644 --- a/packages/sdk/go/api.md +++ b/packages/sdk/go/api.md @@ -103,6 +103,7 @@ Response Types: - opencode.ToolStatePending - opencode.ToolStateRunning - opencode.UserMessage +- opencode.SessionMessageResponse - opencode.SessionMessagesResponse Methods: @@ -113,6 +114,7 @@ Methods: - client.Session.Abort(ctx context.Context, id string) (bool, error) - client.Session.Chat(ctx context.Context, id string, body opencode.SessionChatParams) (opencode.AssistantMessage, error) - client.Session.Init(ctx context.Context, id string, body opencode.SessionInitParams) (bool, error) +- client.Session.Message(ctx context.Context, id string, messageID string) (opencode.SessionMessageResponse, error) - client.Session.Messages(ctx context.Context, id string) ([]opencode.SessionMessagesResponse, error) - client.Session.Revert(ctx context.Context, id string, body opencode.SessionRevertParams) (opencode.Session, error) - client.Session.Share(ctx context.Context, id string) (opencode.Session, error) @@ -120,6 +122,16 @@ Methods: - client.Session.Unrevert(ctx context.Context, id string) (opencode.Session, error) - client.Session.Unshare(ctx context.Context, id string) (opencode.Session, error) +## Permissions + +Response Types: + +- opencode.Permission + +Methods: + +- client.Session.Permissions.Respond(ctx context.Context, id string, permissionID string, body opencode.SessionPermissionRespondParams) (bool, error) + # Tui Methods: diff --git a/packages/sdk/go/event.go b/packages/sdk/go/event.go index 5203ab23..3c08b327 100644 --- a/packages/sdk/go/event.go +++ b/packages/sdk/go/event.go @@ -54,8 +54,7 @@ type EventListResponse struct { // [EventListResponseEventMessageRemovedProperties], // [EventListResponseEventMessagePartUpdatedProperties], // [EventListResponseEventMessagePartRemovedProperties], - // [EventListResponseEventStorageWriteProperties], - // [EventListResponseEventPermissionUpdatedProperties], + // [EventListResponseEventStorageWriteProperties], [Permission], // [EventListResponseEventFileEditedProperties], // [EventListResponseEventSessionUpdatedProperties], // [EventListResponseEventSessionDeletedProperties], @@ -643,9 +642,9 @@ func (r EventListResponseEventStorageWriteType) IsKnown() bool { } type EventListResponseEventPermissionUpdated struct { - Properties EventListResponseEventPermissionUpdatedProperties `json:"properties,required"` - Type EventListResponseEventPermissionUpdatedType `json:"type,required"` - JSON eventListResponseEventPermissionUpdatedJSON `json:"-"` + Properties Permission `json:"properties,required"` + Type EventListResponseEventPermissionUpdatedType `json:"type,required"` + JSON eventListResponseEventPermissionUpdatedJSON `json:"-"` } // eventListResponseEventPermissionUpdatedJSON contains the JSON metadata for the @@ -667,56 +666,6 @@ func (r eventListResponseEventPermissionUpdatedJSON) RawJSON() string { func (r EventListResponseEventPermissionUpdated) implementsEventListResponse() {} -type EventListResponseEventPermissionUpdatedProperties struct { - ID string `json:"id,required"` - Metadata map[string]interface{} `json:"metadata,required"` - SessionID string `json:"sessionID,required"` - Time EventListResponseEventPermissionUpdatedPropertiesTime `json:"time,required"` - Title string `json:"title,required"` - JSON eventListResponseEventPermissionUpdatedPropertiesJSON `json:"-"` -} - -// eventListResponseEventPermissionUpdatedPropertiesJSON contains the JSON metadata -// for the struct [EventListResponseEventPermissionUpdatedProperties] -type eventListResponseEventPermissionUpdatedPropertiesJSON struct { - ID apijson.Field - Metadata apijson.Field - SessionID apijson.Field - Time apijson.Field - Title apijson.Field - raw string - ExtraFields map[string]apijson.Field -} - -func (r *EventListResponseEventPermissionUpdatedProperties) UnmarshalJSON(data []byte) (err error) { - return apijson.UnmarshalRoot(data, r) -} - -func (r eventListResponseEventPermissionUpdatedPropertiesJSON) RawJSON() string { - return r.raw -} - -type EventListResponseEventPermissionUpdatedPropertiesTime struct { - Created float64 `json:"created,required"` - JSON eventListResponseEventPermissionUpdatedPropertiesTimeJSON `json:"-"` -} - -// eventListResponseEventPermissionUpdatedPropertiesTimeJSON contains the JSON -// metadata for the struct [EventListResponseEventPermissionUpdatedPropertiesTime] -type eventListResponseEventPermissionUpdatedPropertiesTimeJSON struct { - Created apijson.Field - raw string - ExtraFields map[string]apijson.Field -} - -func (r *EventListResponseEventPermissionUpdatedPropertiesTime) UnmarshalJSON(data []byte) (err error) { - return apijson.UnmarshalRoot(data, r) -} - -func (r eventListResponseEventPermissionUpdatedPropertiesTimeJSON) RawJSON() string { - return r.raw -} - type EventListResponseEventPermissionUpdatedType string const ( diff --git a/packages/sdk/go/session.go b/packages/sdk/go/session.go index 2598d51c..d38c37e0 100644 --- a/packages/sdk/go/session.go +++ b/packages/sdk/go/session.go @@ -24,7 +24,8 @@ import ( // automatically. You should not instantiate this service directly, and instead use // the [NewSessionService] method instead. type SessionService struct { - Options []option.RequestOption + Options []option.RequestOption + Permissions *SessionPermissionService } // NewSessionService generates a new service that applies the given options to each @@ -33,6 +34,7 @@ type SessionService struct { func NewSessionService(opts ...option.RequestOption) (r *SessionService) { r = &SessionService{} r.Options = opts + r.Permissions = NewSessionPermissionService(opts...) return } @@ -100,6 +102,22 @@ func (r *SessionService) Init(ctx context.Context, id string, body SessionInitPa return } +// Get a message from a session +func (r *SessionService) Message(ctx context.Context, id string, messageID string, opts ...option.RequestOption) (res *SessionMessageResponse, err error) { + opts = append(r.Options[:], opts...) + if id == "" { + err = errors.New("missing required id parameter") + return + } + if messageID == "" { + err = errors.New("missing required messageID parameter") + return + } + path := fmt.Sprintf("session/%s/message/%s", id, messageID) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) + return +} + // List messages for a session func (r *SessionService) Messages(ctx context.Context, id string, opts ...option.RequestOption) (res *[]SessionMessagesResponse, err error) { opts = append(r.Options[:], opts...) @@ -2012,6 +2030,29 @@ func (r userMessageTimeJSON) RawJSON() string { return r.raw } +type SessionMessageResponse struct { + Info Message `json:"info,required"` + Parts []Part `json:"parts,required"` + JSON sessionMessageResponseJSON `json:"-"` +} + +// sessionMessageResponseJSON contains the JSON metadata for the struct +// [SessionMessageResponse] +type sessionMessageResponseJSON struct { + Info apijson.Field + Parts apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *SessionMessageResponse) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r sessionMessageResponseJSON) RawJSON() string { + return r.raw +} + type SessionMessagesResponse struct { Info Message `json:"info,required"` Parts []Part `json:"parts,required"` diff --git a/packages/sdk/go/session_test.go b/packages/sdk/go/session_test.go index 295e9e7c..ab9fbcf7 100644 --- a/packages/sdk/go/session_test.go +++ b/packages/sdk/go/session_test.go @@ -176,6 +176,32 @@ func TestSessionInit(t *testing.T) { } } +func TestSessionMessage(t *testing.T) { + t.Skip("skipped: tests are disabled for the time being") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + _, err := client.Session.Message( + context.TODO(), + "id", + "messageID", + ) + if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + func TestSessionMessages(t *testing.T) { t.Skip("skipped: tests are disabled for the time being") baseURL := "http://localhost:4010" diff --git a/packages/sdk/go/sessionpermission.go b/packages/sdk/go/sessionpermission.go new file mode 100644 index 00000000..85e55bd5 --- /dev/null +++ b/packages/sdk/go/sessionpermission.go @@ -0,0 +1,130 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package opencode + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/sst/opencode-sdk-go/internal/apijson" + "github.com/sst/opencode-sdk-go/internal/param" + "github.com/sst/opencode-sdk-go/internal/requestconfig" + "github.com/sst/opencode-sdk-go/option" +) + +// SessionPermissionService contains methods and other services that help with +// interacting with the opencode API. +// +// Note, unlike clients, this service does not read variables from the environment +// automatically. You should not instantiate this service directly, and instead use +// the [NewSessionPermissionService] method instead. +type SessionPermissionService struct { + Options []option.RequestOption +} + +// NewSessionPermissionService generates a new service that applies the given +// options to each request. These options are applied after the parent client's +// options (if there is one), and before any request-specific options. +func NewSessionPermissionService(opts ...option.RequestOption) (r *SessionPermissionService) { + r = &SessionPermissionService{} + r.Options = opts + return +} + +// Respond to a permission request +func (r *SessionPermissionService) Respond(ctx context.Context, id string, permissionID string, body SessionPermissionRespondParams, opts ...option.RequestOption) (res *bool, err error) { + opts = append(r.Options[:], opts...) + if id == "" { + err = errors.New("missing required id parameter") + return + } + if permissionID == "" { + err = errors.New("missing required permissionID parameter") + return + } + path := fmt.Sprintf("session/%s/permissions/%s", id, permissionID) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) + return +} + +type Permission struct { + ID string `json:"id,required"` + MessageID string `json:"messageID,required"` + Metadata map[string]interface{} `json:"metadata,required"` + SessionID string `json:"sessionID,required"` + Time PermissionTime `json:"time,required"` + Title string `json:"title,required"` + Type string `json:"type,required"` + CallID string `json:"callID"` + Pattern string `json:"pattern"` + JSON permissionJSON `json:"-"` +} + +// permissionJSON contains the JSON metadata for the struct [Permission] +type permissionJSON struct { + ID apijson.Field + MessageID apijson.Field + Metadata apijson.Field + SessionID apijson.Field + Time apijson.Field + Title apijson.Field + Type apijson.Field + CallID apijson.Field + Pattern apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *Permission) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r permissionJSON) RawJSON() string { + return r.raw +} + +type PermissionTime struct { + Created float64 `json:"created,required"` + JSON permissionTimeJSON `json:"-"` +} + +// permissionTimeJSON contains the JSON metadata for the struct [PermissionTime] +type permissionTimeJSON struct { + Created apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *PermissionTime) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r permissionTimeJSON) RawJSON() string { + return r.raw +} + +type SessionPermissionRespondParams struct { + Response param.Field[SessionPermissionRespondParamsResponse] `json:"response,required"` +} + +func (r SessionPermissionRespondParams) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) +} + +type SessionPermissionRespondParamsResponse string + +const ( + SessionPermissionRespondParamsResponseOnce SessionPermissionRespondParamsResponse = "once" + SessionPermissionRespondParamsResponseAlways SessionPermissionRespondParamsResponse = "always" + SessionPermissionRespondParamsResponseReject SessionPermissionRespondParamsResponse = "reject" +) + +func (r SessionPermissionRespondParamsResponse) IsKnown() bool { + switch r { + case SessionPermissionRespondParamsResponseOnce, SessionPermissionRespondParamsResponseAlways, SessionPermissionRespondParamsResponseReject: + return true + } + return false +} diff --git a/packages/sdk/go/sessionpermission_test.go b/packages/sdk/go/sessionpermission_test.go new file mode 100644 index 00000000..728976be --- /dev/null +++ b/packages/sdk/go/sessionpermission_test.go @@ -0,0 +1,43 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package opencode_test + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/sst/opencode-sdk-go" + "github.com/sst/opencode-sdk-go/internal/testutil" + "github.com/sst/opencode-sdk-go/option" +) + +func TestSessionPermissionRespond(t *testing.T) { + t.Skip("skipped: tests are disabled for the time being") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + _, err := client.Session.Permissions.Respond( + context.TODO(), + "id", + "permissionID", + opencode.SessionPermissionRespondParams{ + Response: opencode.F(opencode.SessionPermissionRespondParamsResponseOnce), + }, + ) + if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} diff --git a/packages/tui/go.mod b/packages/tui/go.mod index bf2812ca..6dff3e7e 100644 --- a/packages/tui/go.mod +++ b/packages/tui/go.mod @@ -24,7 +24,7 @@ require ( replace ( github.com/charmbracelet/x/input => ./input - github.com/sst/opencode-sdk-go => ./sdk + github.com/sst/opencode-sdk-go => ../sdk/go ) require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index 96ea8241..412d2c4c 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -469,7 +469,7 @@ func (m *messagesComponent) renderView() tea.Cmd { } permission := opencode.Permission{} - if m.app.CurrentPermission.ToolCallID == part.CallID { + if m.app.CurrentPermission.CallID == part.CallID { permission = m.app.CurrentPermission } @@ -640,7 +640,7 @@ func (m *messagesComponent) renderView() tea.Cmd { slog.Error("Failed to get message from child session", "error", err) } else { for _, part := range response.Parts { - if part.CallID == m.app.CurrentPermission.ToolCallID { + if part.CallID == m.app.CurrentPermission.CallID { content := renderToolDetails( m.app, part.AsUnion().(opencode.ToolPart),