diff --git a/agents.md b/agents.md index bd4a91a5..2949becc 100644 --- a/agents.md +++ b/agents.md @@ -59,7 +59,7 @@ A push that fails with tangled's `too many concurrent operations from your addre - **`src/albedo/daemon/`** — session actors and durable state: submission/run/turn plumbing (`session_*.gleam`), the append-only transcript and conversation store (SQLite via sqlight), the event bus, projections, the family (agents, mail, bus), folders, quota, usage, the reaper, persisted settings, the image store, and `server.gleam`, the HTTP entry point, whose listener runs under `listener.gleam`: a listener tree that gives up is restarted with backoff on the same port, so the daemon outlives it. - **`src/albedo/daemon/migrations/`** — existing SQLite upgrades, with ordered startup data steps in `daemon/migrations.gleam`; schema additions remain called from owning initialisers (see `robot-docs/migrations.md`). - **`src/albedo/harness/`** — the model-facing half: `loop.gleam` (the tool loop), `tool.gleam`, `extension.gleam` (plugin interfaces, installation), `extension/composition.gleam` and `extension/selection.gleam` (a session's composed plugins; its saved selection), `extensions.gleam` (the built-in list), `compaction.gleam`, `hook.gleam` (what a turn asks extensions' hooks and how their verdicts combine), `runtime.gleam` (the runtime actor's public api and message loop; its state, scheduler, kernel plumbing, preparation, reload, and upgrade live under `runtime/`), plus credentials, oauth, rotation, session settings mutations, shared settings persistence, extension settings, and the usage feed. -- **`src/albedo/harness/extensions/`** — one directory per extension. Enabled by default: `python`, `run`, `work`, `mail`, `agents`, `schedule`, `paperclips`, `artifacts`, `files`, `memory`, `links`, `direnv`, `instructions`, `commands`, `skills`, `models`, `openai`, `codex`, `antigravity`, `alibaba`, `opencode-go`, `opencode-zen`, `bedrock`, `claude`, `vertex`, `rolling`, `snapcompact-memory`, `lcm-memory`, `notes`, `remote`, `browser`, `warm`, `exa`, `web-search`. Installed but off until enabled: `cua`, `mcp`, `view`, `proxy`, `webhooks`, `discord`, `hooks`, `snapcompact`, `lcm`. The provider extensions are ordered before models.dev on purpose (their own model lists win), and the memory/compaction pairs are ordered so archives cover past folds. +- **`src/albedo/harness/extensions/`** — one directory per extension. Enabled by default: `python`, `run`, `work`, `mail`, `agents`, `schedule`, `paperclips`, `artifacts`, `files`, `memory`, `links`, `instructions`, `commands`, `skills`, `models`, `openai`, `codex`, `antigravity`, `alibaba`, `opencode-go`, `opencode-zen`, `bedrock`, `claude`, `vertex`, `rolling`, `snapcompact-memory`, `lcm-memory`, `notes`, `remote`, `browser`, `warm`, `exa`, `web-search`. Installed but off until enabled: `cua`, `mcp`, `view`, `proxy`, `webhooks`, `discord`, `hooks`, `snapcompact`, `lcm`. The provider extensions are ordered before models.dev on purpose (their own model lists win), and the memory/compaction pairs are ordered so archives cover past folds. - **`src/albedo/openai_api/`** — the provider transport every extension shares: request/turn types, SSE framing, stream reducers, replay after restarts, chat/responses encodings. A provider with a foreign wire format encodes an `openai_api.Exchange` and supplies only its own reducer (antigravity does this); HTTP, retries, limits, and callbacks stay shared. The extension record is the core abstraction everything composes through: diff --git a/cli/internal/daemon/direnv.go b/cli/internal/daemon/direnv.go deleted file mode 100644 index 44e0ec50..00000000 --- a/cli/internal/daemon/direnv.go +++ /dev/null @@ -1,60 +0,0 @@ -package daemon - -import ( - "context" - "io" - "net/http" - - "albedo/cli/internal/daemon/protocol" -) - -// DirenvFolder is a workspace's .envrc and what the person chose for it. -type DirenvFolder struct { - Workspace string - Envrc string // empty when there is none - Choice string // "load", "ignore", or empty before a choice - Ask bool // nothing chosen yet, or the file changed since it was allowed -} - -// Direnv reads the workspace's .envrc. A daemon without the direnv extension -// answers 404. -func (c *ChatClient) Direnv(ctx context.Context, workspace string) (DirenvFolder, error) { - params := protocol.GetDirenvFolderParams{Workspace: workspace} - var folder DirenvFolder - err := executeRead(ctx, c.conn, operation{Name: "read direnv folder", BuildRequest: func(base string, _ io.Reader) (*http.Request, error) { - return protocol.NewGetDirenvFolderRequest(base, ¶ms) - }, Policy: readRecovery}, func(data []byte) error { - var err error - folder, err = direnvFolder(data, workspace) - return err - }) - return folder, err -} - -// ChooseDirenv records "load" or "ignore" for the workspace; "load" lets -// direnv trust the file as it is now. -func (c *ChatClient) ChooseDirenv(ctx context.Context, workspace, choice string) (DirenvFolder, error) { - params := protocol.PutDirenvFolderParams{Workspace: workspace} - headers := http.Header{} - headers.Set("Content-Type", "application/json") - var folder DirenvFolder - err := executeMutation(ctx, c.conn, operation{Name: "choose direnv folder", BuildRequest: func(base string, body io.Reader) (*http.Request, error) { - return protocol.NewPutDirenvFolderRequestWithBody(base, ¶ms, "application/json", body) - }, Headers: headers, Body: protocol.DirenvChoice{Choice: choice}, Policy: noRecovery}, []int{http.StatusOK}, func(data []byte, _ int) error { - var err error - folder, err = direnvFolder(data, workspace) - return err - }) - return folder, err -} - -func direnvFolder(data []byte, workspace string) (DirenvFolder, error) { - var w protocol.DirenvFolder - if err := decodeRequired(data, &w); err != nil { - return DirenvFolder{}, err - } - if w.Workspace != workspace || w.Ask && w.Envrc == nil { - return DirenvFolder{}, fieldError("direnv folder") - } - return DirenvFolder{Workspace: w.Workspace, Envrc: value(w.Envrc), Choice: value(w.Choice), Ask: w.Ask}, nil -} diff --git a/cli/internal/daemon/protocol/api.gen.go b/cli/internal/daemon/protocol/api.gen.go index d570dd2d..a60afdec 100644 --- a/cli/internal/daemon/protocol/api.gen.go +++ b/cli/internal/daemon/protocol/api.gen.go @@ -759,31 +759,6 @@ type DeliveryPage struct { Next *PageToken `json:"next" nullable:"true"` } -// DirenvChoice defines model for DirenvChoice. -type DirenvChoice struct { - Choice string `json:"choice"` -} - -// DirenvFolder Examples: {"allowed":false,"ask":true,"choice":null,"envrc":"/Users/dawn/proj/.envrc","page":{"actions":[],"empty_state":"no .envrc in this folder","glance":null,"rows":[],"summary":"not chosen","title":"direnv"},"problem":null,"workspace":"/Users/dawn/proj"} -type DirenvFolder struct { - // Allowed Whether direnv trusts the file's current content. - Allowed bool `json:"allowed"` - - // Ask Whether a client should ask the person now. - Ask bool `json:"ask"` - Choice *string `json:"choice" nullable:"true"` - - // Envrc The file direnv loads for the workspace, in it or above it. - Envrc *string `json:"envrc" nullable:"true"` - Page PageDescriptor `json:"page"` - - // Problem Why nothing can load here, for a person to read. - Problem *string `json:"problem" nullable:"true"` - - // Workspace Canonical daemon-host location, local or SSH. Clients do not resolve this path locally. - Workspace Workspace `json:"workspace"` -} - // DiscordAllow defines model for DiscordAllow. type DiscordAllow struct { Name *string `json:"name" nullable:"true"` @@ -3662,16 +3637,6 @@ type ListArtifactsParams struct { Session *ID `form:"session,omitempty" json:"session,omitempty"` } -// GetDirenvFolderParams defines parameters for GetDirenvFolder. -type GetDirenvFolderParams struct { - Workspace Workspace `form:"workspace" json:"workspace"` -} - -// PutDirenvFolderParams defines parameters for PutDirenvFolder. -type PutDirenvFolderParams struct { - Workspace Workspace `form:"workspace" json:"workspace"` -} - // DeleteDiscordBotParams defines parameters for DeleteDiscordBot. type DeleteDiscordBotParams struct { Revision int64 `form:"revision" json:"revision"` @@ -4025,9 +3990,6 @@ type ReportArtifactErrorTextRequestBody = ArtifactError // SaveArtifactStateTextRequestBody defines body for SaveArtifactState for text/plain ContentType. type SaveArtifactStateTextRequestBody = ArtifactState -// PutDirenvFolderJSONRequestBody defines body for PutDirenvFolder for application/json ContentType. -type PutDirenvFolderJSONRequestBody = DirenvChoice - // CreateDiscordBotJSONRequestBody defines body for CreateDiscordBot for application/json ContentType. type CreateDiscordBotJSONRequestBody = DiscordBotCreate @@ -4343,31 +4305,6 @@ type ClientInterface interface { // Corresponds with GET /extensions/artifacts/items/{artifact_id} (the `GetArtifact` operationId). GetArtifact(ctx context.Context, artifactID ArtifactID, reqEditors ...RequestEditorFn) (*http.Response, error) - // GetDirenvFolder Read a workspace's .envrc and whether its jobs load it - // - // Runs `direnv status` in a local workspace. `ask` is true while nothing is chosen, or when the file changed since the person chose to load it. A remote workspace or a missing direnv answers with `problem` and no file. - // - // Corresponds with GET /extensions/direnv/folders (the `GetDirenvFolder` operationId). - GetDirenvFolder(ctx context.Context, params *GetDirenvFolderParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // PutDirenvFolderWithBody Choose whether a workspace's jobs load its .envrc - // - // Human-only. `load` runs `direnv allow` on the file as it is now, then records the choice; `ignore` only records it. Either applies to every session in the workspace from its next `run()`. Repeating a choice is harmless. - // - // Takes any type of body and a specified content type. - // - // Corresponds with PUT /extensions/direnv/folders (the `PutDirenvFolder` operationId). - PutDirenvFolderWithBody(ctx context.Context, params *PutDirenvFolderParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - // PutDirenvFolder Choose whether a workspace's jobs load its .envrc - // - // Human-only. `load` runs `direnv allow` on the file as it is now, then records the choice; `ignore` only records it. Either applies to every session in the workspace from its next `run()`. Repeating a choice is harmless. - // - // Takes a body of the `application/json` content type. - // - // Corresponds with PUT /extensions/direnv/folders (the `PutDirenvFolder` operationId). - PutDirenvFolder(ctx context.Context, params *PutDirenvFolderParams, body PutDirenvFolderJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - // ListDiscordBots List Discord bots of every session without tokens // // Corresponds with GET /extensions/discord/bots (the `ListDiscordBots` operationId). @@ -5576,61 +5513,6 @@ func (c *Client) GetArtifact(ctx context.Context, artifactID ArtifactID, reqEdit return c.Client.Do(req) } -// GetDirenvFolder Read a workspace's .envrc and whether its jobs load it -// -// Runs `direnv status` in a local workspace. `ask` is true while nothing is chosen, or when the file changed since the person chose to load it. A remote workspace or a missing direnv answers with `problem` and no file. -// -// Corresponds with GET /extensions/direnv/folders (the `GetDirenvFolder` operationId). -func (c *Client) GetDirenvFolder(ctx context.Context, params *GetDirenvFolderParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewGetDirenvFolderRequest(c.Server, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -// PutDirenvFolderWithBody Choose whether a workspace's jobs load its .envrc -// -// Human-only. `load` runs `direnv allow` on the file as it is now, then records the choice; `ignore` only records it. Either applies to every session in the workspace from its next `run()`. Repeating a choice is harmless. -// -// Takes any type of body and a specified content type. -// -// Corresponds with PUT /extensions/direnv/folders (the `PutDirenvFolder` operationId). -func (c *Client) PutDirenvFolderWithBody(ctx context.Context, params *PutDirenvFolderParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewPutDirenvFolderRequestWithBody(c.Server, params, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -// PutDirenvFolder Choose whether a workspace's jobs load its .envrc -// -// Human-only. `load` runs `direnv allow` on the file as it is now, then records the choice; `ignore` only records it. Either applies to every session in the workspace from its next `run()`. Repeating a choice is harmless. -// -// Takes a body of the `application/json` content type. -// -// Corresponds with PUT /extensions/direnv/folders (the `PutDirenvFolder` operationId). -func (c *Client) PutDirenvFolder(ctx context.Context, params *PutDirenvFolderParams, body PutDirenvFolderJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewPutDirenvFolderRequest(c.Server, params, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - // ListDiscordBots List Discord bots of every session without tokens // // Corresponds with GET /extensions/discord/bots (the `ListDiscordBots` operationId). @@ -8253,119 +8135,6 @@ func NewGetArtifactRequest(server string, artifactID ArtifactID) (*http.Request, return req, nil } -// NewGetDirenvFolderRequest constructs an http.Request for the GetDirenvFolder method -func NewGetDirenvFolderRequest(server string, params *GetDirenvFolderParams) (*http.Request, error) { - var err error - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/extensions/direnv/folders") - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "workspace", params.Workspace, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewPutDirenvFolderRequest calls the generic PutDirenvFolder builder with application/json body -func NewPutDirenvFolderRequest(server string, params *PutDirenvFolderParams, body PutDirenvFolderJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewPutDirenvFolderRequestWithBody(server, params, "application/json", bodyReader) -} - -// NewPutDirenvFolderRequestWithBody constructs an http.Request for the PutDirenvFolder method, with any body, and a specified content type -func NewPutDirenvFolderRequestWithBody(server string, params *PutDirenvFolderParams, contentType string, body io.Reader) (*http.Request, error) { - var err error - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/extensions/direnv/folders") - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "workspace", params.Workspace, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - // NewListDiscordBotsRequest constructs an http.Request for the ListDiscordBots method func NewListDiscordBotsRequest(server string) (*http.Request, error) { var err error diff --git a/cli/internal/tui/chat.go b/cli/internal/tui/chat.go index 51631b47..f5d8f3a3 100644 --- a/cli/internal/tui/chat.go +++ b/cli/internal/tui/chat.go @@ -138,9 +138,9 @@ type ChatModel struct { // userRows are the settled rows where user messages start. userRows []int effortOptions []string - // direnvAsk is the folder whose .envrc waits for a choice, shown above - // the composer; nil when nothing asks. - direnvAsk *daemon.DirenvFolder + // direnvAsk is the blocked .envrc the question above the composer offers + // to allow; empty when nothing asks. + direnvAsk string // burstRows is the rendered frame of the trailing run of compact entries. // Its key names every input the rows depend on, so a refresh that changes diff --git a/cli/internal/tui/chat_direnv.go b/cli/internal/tui/chat_direnv.go index 3c9874e3..eccef1f0 100644 --- a/cli/internal/tui/chat_direnv.go +++ b/cli/internal/tui/chat_direnv.go @@ -2,7 +2,11 @@ package tui import ( "context" + "encoding/json" + "errors" + "os/exec" "strings" + "time" "albedo/cli/internal/daemon" @@ -10,58 +14,95 @@ import ( "github.com/charmbracelet/x/ansi" ) -// ChatDirenvMsg answers a read of the session folder's .envrc, or a choice -// for it (Chose names the choice; empty for a read). +// ChatDirenvMsg answers whether the session's folder has a .envrc direnv +// blocks, or, with Allow, how allowing it went. type ChatDirenvMsg struct { Err error SessionID string Workspace string - Chose string - Folder daemon.DirenvFolder + Envrc string + Allow bool Generation int64 } -// direnvCmd reads whether the folder's .envrc waits for a choice: when the -// chat opens and after a move. A daemon without the direnv extension, or any -// failure, leaves the question away. +// direnvCmd asks direnv whether the folder has a .envrc it blocks, when the +// chat opens and after a move. `direnv allow` is the person's own act on +// their own machine, so it asks only when the daemon and the workspace are +// both on this one. func (m ChatModel) direnvCmd() tea.Cmd { - if m.client == nil || m.Workspace == "" { + if host, _ := daemon.SplitLocation(m.Workspace); m.client == nil || host != "" || !m.client.LocalDaemon() { return nil } - client, ctx, id, generation, workspace := m.client, m.streamCtx, m.SessionID, m.Generation, m.Workspace + id, generation, workspace := m.SessionID, m.Generation, m.Workspace return func() tea.Msg { - folder, err := client.Direnv(ctx, workspace) - return ChatDirenvMsg{SessionID: id, Generation: generation, Workspace: workspace, Folder: folder, Err: err} + envrc, err := blockedEnvrc(workspace) + return ChatDirenvMsg{SessionID: id, Generation: generation, Workspace: workspace, Envrc: envrc, Err: err} } } +// blockedEnvrc is the .envrc direnv would load for dir but blocks until it is +// allowed; "" when there is none, direnv allows or denies it, or direnv is not +// installed. +func blockedEnvrc(dir string) (string, error) { + if _, err := exec.LookPath("direnv"); err != nil { + return "", nil + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "direnv", "status", "--json") + command.Dir = dir + out, err := command.Output() + if err != nil { + return "", err + } + var status struct { + State struct { + FoundRC *struct { + Path string `json:"path"` + Allowed int `json:"allowed"` // 0 allowed, 1 blocked, 2 denied + } `json:"foundRC"` + } `json:"state"` + } + if err := json.Unmarshal(out, &status); err != nil { + return "", err + } + if found := status.State.FoundRC; found != nil && found.Allowed == 1 { + return found.Path, nil + } + return "", nil +} + // answerDirenv handles a key while the question is open over an empty -// composer: y loads the .envrc, n ignores it, esc puts the question away -// until the chat opens again. Other typing is held back, so the first letter -// of a prompt never answers by accident; keys that type nothing pass on. +// composer: y runs `direnv allow`, n (or esc) puts the question away until +// the chat opens again, as direnv asks again at the next cd. Other typing is +// held back, so the first letter of a prompt never answers by accident; keys +// that type nothing pass on. func (m *ChatModel) answerDirenv(msg tea.KeyPressMsg) (tea.Cmd, bool) { switch { - case msg.String() == "esc": - m.direnvAsk = nil + case strings.EqualFold(msg.Text, "y"): + return m.allowEnvrc(), true + case strings.EqualFold(msg.Text, "n"), msg.String() == "esc": + m.direnvAsk = "" m.syncLayout() return nil, true - case strings.EqualFold(msg.Text, "y"): - return m.chooseDirenv("load"), true - case strings.EqualFold(msg.Text, "n"): - return m.chooseDirenv("ignore"), true case msg.Text != "": return nil, true } return nil, false } -func (m *ChatModel) chooseDirenv(choice string) tea.Cmd { - m.direnvAsk = nil +func (m *ChatModel) allowEnvrc() tea.Cmd { + envrc, id, generation, workspace := m.direnvAsk, m.SessionID, m.Generation, m.Workspace + m.direnvAsk = "" m.syncLayout() - client, id, generation, workspace := m.client, m.SessionID, m.Generation, m.Workspace return func() tea.Msg { - folder, err := client.ChooseDirenv(context.Background(), workspace, choice) - return ChatDirenvMsg{SessionID: id, Generation: generation, Workspace: workspace, Chose: choice, Folder: folder, Err: err} + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "direnv", "allow", envrc).CombinedOutput() + if said := strings.TrimSpace(string(out)); err != nil && said != "" { + err = errors.New(said) + } + return ChatDirenvMsg{SessionID: id, Generation: generation, Workspace: workspace, Envrc: envrc, Allow: true, Err: err} } } @@ -69,40 +110,28 @@ func (m *ChatModel) direnvAnswered(msg ChatDirenvMsg) tea.Cmd { if msg.SessionID != m.SessionID || msg.Generation != m.Generation || msg.Workspace != m.Workspace { return nil } - if msg.Chose == "" { - if msg.Err == nil && (msg.Folder.Ask || m.direnvAsk != nil) { - m.direnvAsk = nil - if msg.Folder.Ask { - m.direnvAsk = &msg.Folder - } - m.syncLayout() - } - return nil - } - if msg.Err != nil { - m.Notices.Add("could not "+msg.Chose+" .envrc: "+msg.Err.Error()+" · /direnv to try again", true) - m.syncLayout() - return nil - } - m.CopyStatus = "jobs here start without .envrc · /direnv to change" - if msg.Chose == "load" { - m.CopyStatus = "jobs here now load " + msg.Folder.Envrc + switch { + case msg.Allow && msg.Err == nil: + m.CopyStatus = "allowed " + msg.Envrc + " · jobs load it from the next run()" + m.copyStatusRevision++ + return m.clearCopyStatusCmd() + case msg.Allow: + m.Notices.Add("direnv allow "+msg.Envrc+" failed: "+msg.Err.Error(), true) + case msg.Err == nil: + m.direnvAsk = msg.Envrc } - m.copyStatusRevision++ - return m.clearCopyStatusCmd() + m.syncLayout() + return nil } // direnvRows is the question above the composer: one row, or two when the // keys do not fit beside it. func (m ChatModel) direnvRows(width int) []string { - if m.direnvAsk == nil { + if m.direnvAsk == "" { return nil } - question := "this folder has a .envrc · load it into run() jobs here?" - if m.direnvAsk.Choice == "load" { - question = ".envrc changed since you allowed it · load the new one?" - } - keys := keyHints(hint{"y", "load"}, hint{"n", "ignore"}, hint{"esc", "later"}) + question := "this folder's .envrc is blocked · direnv allow it for run() jobs?" + keys := keyHints(hint{"y", "allow"}, hint{"n", "not now"}) if ansi.StringWidth(question)+3+ansi.StringWidth(keys) <= width { return []string{DefaultStyles.Warning.Render(question) + " " + keys} } diff --git a/cli/internal/tui/chat_direnv_test.go b/cli/internal/tui/chat_direnv_test.go index 55365b56..d9a49c1c 100644 --- a/cli/internal/tui/chat_direnv_test.go +++ b/cli/internal/tui/chat_direnv_test.go @@ -1,6 +1,9 @@ package tui import ( + "os" + "os/exec" + "path/filepath" "testing" "albedo/cli/internal/daemon" @@ -13,9 +16,9 @@ func direnvAskingChat(t *testing.T) ChatModel { session := &daemon.Session{ID: "s", Workspace: "/Users/dawn/proj"} m := newTestChatModel(t, session) m.SetSize(100, 30) - m, _ = m.Update(ChatDirenvMsg{SessionID: "s", Generation: m.Generation, Workspace: session.Workspace, Folder: daemon.DirenvFolder{Workspace: session.Workspace, Envrc: "/Users/dawn/proj/.envrc", Ask: true}}) - if m.direnvAsk == nil { - t.Fatal("a folder that asks did not open the question") + m, _ = m.Update(ChatDirenvMsg{SessionID: "s", Generation: m.Generation, Workspace: session.Workspace, Envrc: "/Users/dawn/proj/.envrc"}) + if m.direnvAsk == "" { + t.Fatal("a blocked .envrc did not open the question") } return m } @@ -27,24 +30,51 @@ func letter(r rune) tea.KeyPressMsg { return tea.KeyPressMsg{Code: r, Text: stri func TestDirenvQuestionHoldsTypingUntilAnswered(t *testing.T) { m := direnvAskingChat(t) m, _ = m.Update(letter('h')) - if m.direnvAsk == nil || m.TextArea.Value() != "" { - t.Fatalf("another letter answered or typed: asking=%v composer=%q", m.direnvAsk != nil, m.TextArea.Value()) + if m.direnvAsk == "" || m.TextArea.Value() != "" { + t.Fatalf("another letter answered or typed: asking=%q composer=%q", m.direnvAsk, m.TextArea.Value()) } m, cmd := m.Update(letter('y')) - if m.direnvAsk != nil || cmd == nil { + if m.direnvAsk != "" || cmd == nil { t.Fatal("y did not answer the question") } m = direnvAskingChat(t) m.TextArea.SetValue("draft ") m, _ = m.Update(letter('n')) - if m.direnvAsk == nil || m.TextArea.Value() != "draft n" { + if m.direnvAsk == "" || m.TextArea.Value() != "draft n" { t.Fatalf("n over a draft answered instead of typing: composer=%q", m.TextArea.Value()) } m = direnvAskingChat(t) - m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) - if m.direnvAsk != nil { - t.Fatal("esc left the question open") + m, cmd = m.Update(letter('n')) + if m.direnvAsk != "" || cmd != nil { + t.Fatal("n did not put the question away on its own") + } +} + +// blockedEnvrc reads direnv's own JSON, so it runs the real direnv: a new +// .envrc asks, an allowed one does not. +func TestBlockedEnvrcAsksUntilDirenvAllowsIt(t *testing.T) { + if _, err := exec.LookPath("direnv"); err != nil { + t.Skip("needs direnv") + } + home := t.TempDir() + for _, name := range []string{"XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"} { + t.Setenv(name, filepath.Join(home, name)) + } + dir := t.TempDir() + envrc := filepath.Join(dir, ".envrc") + if err := os.WriteFile(envrc, []byte("export A=1\n"), 0o644); err != nil { + t.Fatal(err) + } + found, err := blockedEnvrc(dir) + if err != nil || filepath.Base(found) != ".envrc" { + t.Fatalf("a new .envrc is not blocked: %q %v", found, err) + } + if out, err := exec.Command("direnv", "allow", found).CombinedOutput(); err != nil { + t.Fatalf("direnv allow: %v %s", err, out) + } + if found, err := blockedEnvrc(dir); err != nil || found != "" { + t.Fatalf("an allowed .envrc still asks: %q %v", found, err) } } diff --git a/cli/internal/tui/chat_input.go b/cli/internal/tui/chat_input.go index 9cb20ee7..e77802ff 100644 --- a/cli/internal/tui/chat_input.go +++ b/cli/internal/tui/chat_input.go @@ -38,7 +38,7 @@ func (m *ChatModel) handleInput(msg tea.Msg) (tea.Cmd, bool) { return nil, true } - if m.direnvAsk != nil && m.TextArea.Value() == "" { + if m.direnvAsk != "" && m.TextArea.Value() == "" { if cmd, handled := m.answerDirenv(msg); handled { return cmd, true } diff --git a/cli/internal/tui/chat_operations.go b/cli/internal/tui/chat_operations.go index 8657d0fd..b14f6518 100644 --- a/cli/internal/tui/chat_operations.go +++ b/cli/internal/tui/chat_operations.go @@ -20,7 +20,7 @@ func (m *ChatModel) Moved(moved daemon.Session, retry *WorkspaceRetry) tea.Cmd { // a hint from an earlier move names a folder this one did not leave m.Notices = slices.DeleteFunc(m.Notices, func(n Notice) bool { return strings.HasPrefix(n.Message, linkHintText) }) m.Notices.AddNotice(linkHint(from, workspace, retry)) - m.direnvAsk = nil // the question was about the folder it left + m.direnvAsk = "" // the question was about the folder it left m.syncViewportHeight() m.Workspace, m.Host, m.hostHome = workspace, sessionHost(moved), "" m.Renderer.Workspace = workspace diff --git a/cli/internal/tui/shot_chat_test.go b/cli/internal/tui/shot_chat_test.go index 1ce64ce5..7f124c5b 100644 --- a/cli/internal/tui/shot_chat_test.go +++ b/cli/internal/tui/shot_chat_test.go @@ -54,12 +54,7 @@ func init() { shotState{name: "settled-cjk-title", view: func(w, h int) string { return renderSettledChatShot("東京e\u0301界のファイルを検索する", "# 東京e\u0301界のファイルを検索する\nprint('done')", w, h) }}, - shotState{name: "direnv-ask", view: func(w, h int) string { - return renderDirenvChatShot("", w, h) - }}, - shotState{name: "direnv-changed", view: func(w, h int) string { - return renderDirenvChatShot("load", w, h) - }}, + shotState{name: "direnv-blocked", view: renderDirenvChatShot}, ) } @@ -94,16 +89,13 @@ func renderSettledChatShot(title, code string, width, height int) string { return m.View() } -// renderDirenvChatShot is a fresh chat whose folder's .envrc waits for a -// choice; choice "load" is a file that changed since it was allowed. -func renderDirenvChatShot(choice string, width, height int) string { +// renderDirenvChatShot is a fresh chat whose folder's .envrc direnv blocks. +func renderDirenvChatShot(width, height int) string { conn := daemon.NewConnection(daemon.ConnectionSnapshot{Port: 1}, nil) session := &daemon.Session{ID: "s", Workspace: "/Users/dawn/proj/albedo", Model: "claude-opus-5-5", Provider: "claude"} m := NewChatModel(session, daemon.NewChatClient(conn, session.ID)) m.SetSize(width, height) - m, _ = m.Update(ChatDirenvMsg{SessionID: session.ID, Generation: m.Generation, Workspace: session.Workspace, Folder: daemon.DirenvFolder{ - Workspace: session.Workspace, Envrc: session.Workspace + "/.envrc", Choice: choice, Ask: true, - }}) + m, _ = m.Update(ChatDirenvMsg{SessionID: session.ID, Generation: m.Generation, Workspace: session.Workspace, Envrc: session.Workspace + "/.envrc"}) m.refreshViewportContent() return m.View() } diff --git a/docs/http-api-design.md b/docs/http-api-design.md index 47a3830d..e195ef2e 100644 --- a/docs/http-api-design.md +++ b/docs/http-api-design.md @@ -1513,22 +1513,6 @@ are reported separately from the committed group change. `notification_count` covers all targets; the response includes up to 200 outcomes and `truncated` when the list reaches its count or byte bound. -### direnv - -| Path | Methods | -| --- | --- | -| `/extensions/direnv/folders` | GET, PUT | - -GET requires `workspace` and runs `direnv status` there. It answers the file -direnv would load, whether direnv trusts its content, the stored choice, `ask`, -a `problem` when nothing can load (a remote workspace, no direnv, no file), and -the `/direnv` page. PUT takes `{"choice": "load" | "ignore"}` and answers the -same representation after it. `load` runs `direnv allow` before storing the -choice; without a file it returns `409 no_envrc`, and a refusal from direnv -returns `503 direnv_failed` with nothing stored. Repeating a choice is -harmless, so no precondition guards it. Writes are human-only; the kernel only -reads the choice through its host route. See [direnv](../robot-docs/direnv.md). - ### Webhooks | Path | Methods | Authentication | diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 3ab2061c..f00ee505 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -36,7 +36,6 @@ tags: - name: web-search - name: schedule - name: links -- name: direnv - name: webhooks - name: discord - name: proxy @@ -3695,79 +3694,6 @@ paths: schema: const: configuration - $ref: '#/components/parameters/IfMatch' - /extensions/direnv/folders: - get: - operationId: getDirenvFolder - summary: Read a workspace's .envrc and whether its jobs load it - tags: - - direnv - description: Runs `direnv status` in a local workspace. `ask` is true while nothing is chosen, or - when the file changed since the person chose to load it. A remote workspace or a missing direnv - answers with `problem` and no file. - parameters: - - name: workspace - in: query - required: true - schema: - $ref: '#/components/schemas/Workspace' - responses: - '200': - description: The folder as direnv sees it, and the person's choice. - content: - application/json: - schema: - $ref: '#/components/schemas/DirenvFolder' - '400': - $ref: '#/components/responses/Problem400' - '401': - $ref: '#/components/responses/Problem401' - '405': - $ref: '#/components/responses/Problem405' - '406': - $ref: '#/components/responses/Problem406' - put: - operationId: putDirenvFolder - summary: Choose whether a workspace's jobs load its .envrc - tags: - - direnv - description: Human-only. `load` runs `direnv allow` on the file as it is now, then records the - choice; `ignore` only records it. Either applies to every session in the workspace from its next - `run()`. Repeating a choice is harmless. - parameters: - - name: workspace - in: query - required: true - schema: - $ref: '#/components/schemas/Workspace' - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DirenvChoice' - responses: - '200': - description: The folder after the choice. - content: - application/json: - schema: - $ref: '#/components/schemas/DirenvFolder' - '400': - $ref: '#/components/responses/Problem400' - '401': - $ref: '#/components/responses/Problem401' - '405': - $ref: '#/components/responses/Problem405' - '406': - $ref: '#/components/responses/Problem406' - '409': - $ref: '#/components/responses/Problem409' - '413': - $ref: '#/components/responses/Problem413' - '415': - $ref: '#/components/responses/Problem415' - '503': - $ref: '#/components/responses/Problem503' /extensions/webhooks/hooks: get: operationId: listHooks @@ -11801,60 +11727,6 @@ components: - type: 'null' required: [configuration_resource, presence, next] additionalProperties: false - DirenvChoice: - type: object - properties: - choice: - type: string - enum: [load, ignore] - required: [choice] - additionalProperties: false - DirenvFolder: - type: object - properties: - workspace: - $ref: '#/components/schemas/Workspace' - envrc: - description: The file direnv loads for the workspace, in it or above it. - anyOf: - - type: string - maxLength: 4096 - - type: 'null' - allowed: - description: Whether direnv trusts the file's current content. - type: boolean - choice: - anyOf: - - type: string - enum: [load, ignore] - - type: 'null' - ask: - description: Whether a client should ask the person now. - type: boolean - problem: - description: Why nothing can load here, for a person to read. - anyOf: - - type: string - maxLength: 4096 - - type: 'null' - page: - $ref: '#/components/schemas/PageDescriptor' - required: [workspace, envrc, allowed, choice, ask, problem, page] - additionalProperties: false - examples: - - workspace: /Users/dawn/proj - envrc: /Users/dawn/proj/.envrc - allowed: false - choice: null - ask: true - problem: null - page: - title: direnv - summary: not chosen - empty_state: no .envrc in this folder - glance: null - actions: [] - rows: [] LinkMerge: type: object properties: diff --git a/priv/python/albedo_direnv.py b/priv/python/albedo_direnv.py new file mode 100644 index 00000000..bd85e5b4 --- /dev/null +++ b/priv/python/albedo_direnv.py @@ -0,0 +1,88 @@ +"""direnv for the model's jobs: before each `run()`, the kernel's environment +catches up with the .envrc direnv would load for the kernel's folder, as +direnv's shell hook does before each prompt (robot-docs/direnv.md). The kernel +is the shell: os.chdir into another project loads that one's .envrc, and +leaving it unloads it. + +direnv decides what loads: a file the person allowed loads, a blocked or +denied one does not. `direnv export json` answers only what changed since the +last call (the DIRENV_* variables it leaves in the environment say what it +loaded), so an unchanged .envrc costs a few milliseconds and an edited one +reloads by itself. Jobs inherit os.environ, so every program the kernel starts +afterwards sees the same environment.""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +from pathlib import Path + +import albedo_trace + +# seconds a first evaluation may take: a `use flake` .envrc builds a dev shell +TIMEOUT = 600 +COLOUR = re.compile(r"\x1b\[[0-9;]*m") +BLOCKED = re.compile(r"direnv: error (.*) is blocked\..*") + +# the direnv this kernel runs, found once; "" when there is none +_direnv: str | None = None +# whether this kernel loaded an environment it may have to take back; one the +# daemon inherited from the shell that started it stays alone +_loaded = False + + +def refresh() -> None: + """Load, reload or unload the .envrc for the kernel's folder, printing what + direnv says when something changed. Never raises: a job started without + the environment beats no job.""" + global _direnv, _loaded + if _direnv is None: + _direnv = shutil.which("direnv") or "" + here = os.getcwd() + # nothing to load here, and nothing loaded to take back + if not _direnv or not (_loaded or _has_envrc(Path(here))): + return + try: + with albedo_trace.unobserved(): + done = subprocess.run( + [_direnv, "export", "json"], + cwd=here, + env=dict(os.environ), + capture_output=True, + text=True, + timeout=TIMEOUT, + ) + except subprocess.TimeoutExpired: + print( + f"direnv: gave up loading .envrc for {here} after {TIMEOUT} s; jobs start without it" + ) + return + except OSError as error: + print(f"direnv: could not run {_direnv}: {error}") + return + try: + change = json.loads(done.stdout) if done.stdout.strip() else {} + except json.JSONDecodeError: + change = {} + for name, value in change.items() if isinstance(change, dict) else (): + if value is None: + os.environ.pop(name, None) + elif isinstance(value, str): + os.environ[name] = value + _loaded = "DIRENV_DIR" in os.environ + for line in COLOUR.sub("", done.stderr).splitlines(): + blocked = BLOCKED.fullmatch(line.strip()) + if blocked: + # the person allows it, not the model + line = f"direnv: {blocked[1]} is not allowed, so jobs start without it until the person allows it" + if line.strip(): + print(line) + + +def _has_envrc(directory: Path) -> bool: + return any( + (folder / ".envrc").is_file() for folder in (directory, *directory.parents) + ) diff --git a/priv/python/albedo_plugins/direnv.py b/priv/python/albedo_plugins/direnv.py deleted file mode 100644 index 2730c4f8..00000000 --- a/priv/python/albedo_plugins/direnv.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Brings the kernel's environment up to date with its workspace's .envrc -before each `run()`, as direnv's shell hook does before each prompt -(robot-docs/direnv.md). The person chooses per folder whether it loads; the -daemon keeps that choice and answers `direnv.state`. Jobs inherit os.environ, -so a loaded environment reaches every program the kernel starts after it. - -`direnv export json` answers only what changed since the last call (the -DIRENV_* variables it leaves in the environment say what it loaded), so an -unchanged .envrc costs a few milliseconds, and an edited one reloads itself.""" - -from __future__ import annotations - -import json -import os -import re -import subprocess -from collections.abc import Callable -from pathlib import Path - -from albedo_api import PythonApi -import albedo_trace - -# seconds a first evaluation may take: a `use flake` .envrc builds a dev shell -TIMEOUT = 600 -COLOUR = re.compile(r"\x1b\[[0-9;]*m") -BLOCKED = re.compile(r"direnv: error (.*) is blocked\..*") - -host_now: Callable[[str, dict[str, object]], object] | None = None -# the workspace, from the daemon's first answer; "" when nothing can load -# here (a remote workspace, or no direnv) -_directory: str | None = None -# whether this kernel applied an environment it may have to take back -_loaded = False - - -def setup(api: PythonApi) -> dict[str, object]: - global host_now - host_now = api.host_now - return {} - - -def refresh() -> None: - """Load, reload, or unload the workspace's .envrc in os.environ, printing - what direnv says when something changed. Never raises: a job started - without the environment beats no job.""" - global _directory, _loaded - if host_now is None or _directory == "": - return - # one question per kernel for a folder that has no .envrc - if _directory is not None and not _loaded and not _has_envrc(Path(_directory)): - return - try: - state = host_now("direnv.state", {}) - except Exception: - return - if not isinstance(state, dict): - return - directory, direnv = state.get("directory"), state.get("direnv") - if not isinstance(directory, str) or not isinstance(direnv, str): - _directory = "" - return - _directory = directory - if state.get("load") is True: - _loaded = True - _export(direnv, directory) - elif _loaded: - # from a folder no .envrc covers, direnv takes back what it loaded - _loaded = False - _export(direnv, "/") - - -def _has_envrc(directory: Path) -> bool: - return any( - (folder / ".envrc").is_file() for folder in (directory, *directory.parents) - ) - - -def _export(direnv: str, directory: str) -> None: - try: - with albedo_trace.unobserved(): - done = subprocess.run( - [direnv, "export", "json"], - cwd=directory, - env=dict(os.environ), - capture_output=True, - text=True, - timeout=TIMEOUT, - ) - except subprocess.TimeoutExpired: - print( - f"direnv: gave up loading .envrc for {directory} after {TIMEOUT} s; jobs start without it" - ) - return - except OSError as error: - print(f"direnv: could not run {direnv}: {error}") - return - try: - change = json.loads(done.stdout) if done.stdout.strip() else {} - except json.JSONDecodeError: - change = {} - for name, value in change.items() if isinstance(change, dict) else (): - if value is None: - os.environ.pop(name, None) - elif isinstance(value, str): - os.environ[name] = value - for line in COLOUR.sub("", done.stderr).splitlines(): - blocked = BLOCKED.fullmatch(line.strip()) - if blocked: - line = ( - f"direnv: {blocked[1]} changed since it was allowed, so jobs start " - "without it until the person loads it again (/direnv)" - ) - if line.strip(): - print(line) diff --git a/priv/python/albedo_plugins/run.py b/priv/python/albedo_plugins/run.py index f6ef669c..f8ef54cf 100644 --- a/priv/python/albedo_plugins/run.py +++ b/priv/python/albedo_plugins/run.py @@ -34,9 +34,10 @@ from albedo_api import ( Text, check_timeout, ) +import albedo_direnv import albedo_proc import albedo_shell -from albedo_plugins import direnv, hook +from albedo_plugins import hook import albedo_shims import albedo_trace import asyncio @@ -943,7 +944,7 @@ def run( script = albedo_shell.shell_script(argv) if script is not None: raise albedo_shell.refusal(f"`{os.path.basename(argv[0])} -c`", script=script) - direnv.refresh() + albedo_direnv.refresh() environment = albedo_shims.prepend( None if env is None diff --git a/robot-docs/direnv.md b/robot-docs/direnv.md index 44995a61..f545f217 100644 --- a/robot-docs/direnv.md +++ b/robot-docs/direnv.md @@ -1,68 +1,42 @@ # direnv -a workspace's `.envrc` reaches the model's `run()` jobs once the person lets -it, the way direnv loads it into their own shell. the `direnv` extension -(enabled by default, `src/albedo/harness/extensions/direnv/`) owns it. - -## the choice - -the choice is per workspace, not per session: one answer covers every -session in the folder, the agents it spawns there included, so a folder asks -once. `direnv_folders(workspace, choice)` keeps it, `load` or `ignore` -(`folder.gleam`); nothing is stored for a folder nobody answered. - -whether there is a file and whether direnv trusts it always come from direnv -itself: `GET /extensions/direnv/folders?workspace=` runs `direnv status ---json` in the folder (direnv 2.33 or later) and answers the file it would -load (in the folder or above it), `allowed`, the stored `choice`, and `ask`: -true while nothing is chosen, and again when the person chose `load` but the -file changed since, so direnv blocks it. `PUT` with `{"choice": "load"}` runs -`direnv allow ` first (409 `no_envrc` without a file, 503 -`direnv_failed` when direnv refuses), then stores the choice; `ignore` only -stores it. it is the person's trust, so only they make it: the model has no -binding for it. - -the daemon runs direnv with the kernel's own environment -(`albedo_python:clean_environment`), so `allow` writes the allow list the -kernel's `export` reads. direnv is looked up on the daemon's PATH, then where -package managers put it (`/opt/homebrew/bin`, `/usr/local/bin`, the nix -profiles), for a daemon started with a thin PATH; the kernel is told which one -to run. a remote workspace has nothing to load yet: the GET answers a -`problem`, and the kernel never asks again. +a workspace's `.envrc` reaches the model's `run()` jobs the way direnv loads +it into the person's shell, and direnv's own allow list decides: a file they +allowed loads, a blocked or denied one does not. albedo keeps no state of its +own about it. ## in the kernel -`priv/python/albedo_plugins/direnv.py` brings `os.environ` up to date before -each `run()`, as direnv's shell hook does before each prompt. jobs inherit -`os.environ`, so everything the kernel starts after that (pipes, `files` -searches) sees the same environment. - -- the first `run()` asks the `direnv.state` host route for the folder, - whether to load, and the direnv to run. a folder with no `.envrc` in it or - above it is never asked about again by that kernel, so it costs one host - call per kernel. -- with `load`, it runs `direnv export json` in the workspace and applies the - diff. direnv keeps what it loaded in `DIRENV_*` variables, so an unchanged - file answers nothing in a few milliseconds, and an edited one (or a file it - watches) reloads by itself. a first evaluation (a `use flake` building a dev - shell) may take up to 600 s and holds that `run()` until it finishes. -- after `ignore` replaced `load`, the next `run()` exports from `/`, which - takes back what was loaded. +`priv/python/albedo_direnv.py` runs before each `run()` (`run.py`) and +brings `os.environ` up to date with `direnv export json`, as direnv's shell +hook does before each prompt. the kernel is the shell: its folder is +`os.getcwd()`, so `os.chdir` into another project loads that one's `.envrc`, +and leaving it unloads it. jobs inherit `os.environ`, so everything the kernel +starts afterwards (pipes, `files` searches) sees the same environment. it runs +wherever the kernel does, remote hosts included. + +- direnv is looked up once per kernel on its PATH; without it nothing runs. +- with no `.envrc` in the folder or above it, and nothing this kernel loaded + to take back, it costs a few `stat`s. an environment the daemon inherited + from the shell that started it is left alone. +- an unchanged file answers nothing in a few milliseconds; an edited one (or + a file it watches) reloads by itself. a first evaluation (`use flake` + building a dev shell) may take up to 600 s and holds that `run()`. - what direnv says (`direnv: loading …`, `direnv: export +FOO ~PATH`, `direnv: unloading`) prints into the cell, so the model sees why its - environment changed. a blocked file is reworded to say the person can load - it again with `/direnv` instead of telling the model to run `direnv allow`. -- it never raises: a host or direnv failure starts the job without the - environment. - -## clients - -the tui reads the folder when a chat opens and after `/cd`. while `ask` is -true it shows a question above the composer (`this folder has a .envrc · load -it into run() jobs here?`, or that it changed since it was allowed). while the -composer is empty the question owns the letter keys: `y` loads, `n` ignores, -`esc` puts it away until the chat opens again, and every other letter is held -back, so the first letter of a prompt typed without reading never answers it. -a draft already in the composer keeps typing normally. `/direnv` is a page -with the file, its state, and `load` (ctrl+t, after a confirm naming the -file) and `ignore` (ctrl+x). + environment changed. direnv's blocked message is reworded so it does not + tell the model to run `direnv allow`: allowing is the person's call. +- it never raises: a failure starts the job without the environment. + +## in the tui + +when a chat opens, and after `/cd`, the tui runs `direnv status --json` in +the workspace (`chat_direnv.go`), only when the daemon and the workspace are +both on this machine: `direnv allow` is the person's own act there. for a +file direnv blocks (new, or changed since it was allowed) it shows a question +above the composer. while the composer is empty the question owns the letter +keys: `y` runs `direnv allow ` and the next `run()` loads it, `n` (or +`esc`) puts it away until the chat opens again, as direnv repeats its blocked +message at the next `cd`. every other letter is held back, so the first letter +of a prompt typed without reading never answers it; a draft already in the +composer keeps typing normally. denied files never ask. diff --git a/robot-docs/extensions.md b/robot-docs/extensions.md index 6eee03f0..6983788c 100644 --- a/robot-docs/extensions.md +++ b/robot-docs/extensions.md @@ -10,7 +10,7 @@ extension and dependents; its committed schema remains for the next startup. A startup callback releases any partially started resources before returning an error. Discord uses this callback for its manager, which must not read uncommitted tables. -an extension is a named bundle of plugins. it can contribute any number of context, tool, command, managed (with an optional [hook](hooks.md)), models, provider, login, service, migration, compaction, notes, clean, fold, or [search](web-search.md) plugins, and one settings plugin. `python`, `run`, `work`, `mail`, `agents`, `schedule`, `paperclips`, [`artifacts`](artifacts.md), `files`, `memory`, `links`, [`direnv`](direnv.md), `instructions`, `commands`, `skills`, `models`, `openai`, `codex`, `antigravity`, `alibaba`, `opencode-go`, `opencode-zen`, `bedrock`, `claude`, `vertex`, `rolling`, `snapcompact-memory`, `lcm-memory`, `notes`, `remote`, `browser`, [`warm`](cache-warming.md), `exa`, and [`web-search`](web-search.md) are enabled by default; `cua` ([`cua.md`](cua.md)), `mcp`, `view`, [`proxy`](proxy.md), `webhooks`, [`discord`](discord.md), [`hooks`](hooks.md), and the `snapcompact` and `lcm` compaction strategies are installed and disabled until enabled. plugin contributions compose inside them. +an extension is a named bundle of plugins. it can contribute any number of context, tool, command, managed (with an optional [hook](hooks.md)), models, provider, login, service, migration, compaction, notes, clean, fold, or [search](web-search.md) plugins, and one settings plugin. `python`, `run`, `work`, `mail`, `agents`, `schedule`, `paperclips`, [`artifacts`](artifacts.md), `files`, `memory`, `links`, `instructions`, `commands`, `skills`, `models`, `openai`, `codex`, `antigravity`, `alibaba`, `opencode-go`, `opencode-zen`, `bedrock`, `claude`, `vertex`, `rolling`, `snapcompact-memory`, `lcm-memory`, `notes`, `remote`, `browser`, [`warm`](cache-warming.md), `exa`, and [`web-search`](web-search.md) are enabled by default; `cua` ([`cua.md`](cua.md)), `mcp`, `view`, [`proxy`](proxy.md), `webhooks`, [`discord`](discord.md), [`hooks`](hooks.md), and the `snapcompact` and `lcm` compaction strategies are installed and disabled until enabled. plugin contributions compose inside them. most extensions contribute a Python module: their capabilities are names in the kernel namespace, called inside a python cell (`await vent(...)`, `await view_diff(...)`), not tool calls. an extension may also contribute tools. a call that names no tool but reads as a binding (a dotted Python name such as `files.find` or `web_search`) runs as the python cell that calls it with the call's arguments as keywords, awaited when it returns an awaitable; models trained on native tools call bindings this way. the rewrite (`runtime.as_cell`) happens before the hooks, so they judge the cell that runs. any other unknown name is refused with the answer `no such tool; every capability is exposed through the python kernel, so call it inside a python cell`. diff --git a/robot-docs/tui.md b/robot-docs/tui.md index 2d0b764d..546ac5e5 100644 --- a/robot-docs/tui.md +++ b/robot-docs/tui.md @@ -59,7 +59,7 @@ known exceptions to the table: on `/sessions` and on `/login`'s profile rows `ct `confirm` is the only way a screen asks. enter does it, esc or ctrl+c cancels, every other key is ignored, so a stray press while you read the question changes nothing. never "any other key cancels" and never `y`. a confirm is for destructive or far-reaching actions (delete, a new secret, enabling something that reloads the session); toggles that are cheap to undo act directly. a failed attempt keeps the question open with enter as retry. -the one question a letter answers is the chat's direnv question above an empty composer (`y`, `n`, `esc`); it holds back every other letter instead, so typing past it never answers it ([direnv](direnv.md#clients)). +the one question a letter answers is the chat's direnv question above an empty composer (`y`, `n`); it holds back every other letter instead, so typing past it never answers it ([direnv](direnv.md#in-the-tui)). ## adding a screen diff --git a/src/albedo/harness/extensions.gleam b/src/albedo/harness/extensions.gleam index ace03b4f..10166b9d 100644 --- a/src/albedo/harness/extensions.gleam +++ b/src/albedo/harness/extensions.gleam @@ -10,7 +10,6 @@ import albedo/harness/extensions/codex/extension as codex import albedo/harness/extensions/colab/extension as colab import albedo/harness/extensions/commands/extension as commands import albedo/harness/extensions/cua/extension as cua -import albedo/harness/extensions/direnv/extension as direnv import albedo/harness/extensions/discord/extension as discord import albedo/harness/extensions/exa/extension as exa import albedo/harness/extensions/files/extension as files @@ -65,7 +64,6 @@ fn installed() -> Config { files.extension(), memory.extension(), links.extension(), - direnv.extension(), instructions.extension(), commands.extension(), skills.extension(), @@ -107,9 +105,9 @@ fn installed() -> Config { ], [ "python", "run", "work", "mail", "agents", "schedule", "files", "memory", - "links", "direnv", "instructions", "commands", "skills", "paperclips", - "artifacts", "models", "openai", "codex", "antigravity", "alibaba", - "opencode-go", "opencode-zen", "bedrock", "claude", "vertex", "rolling", + "links", "instructions", "commands", "skills", "paperclips", "artifacts", + "models", "openai", "codex", "antigravity", "alibaba", "opencode-go", + "opencode-zen", "bedrock", "claude", "vertex", "rolling", "snapcompact-memory", "lcm-memory", "notes", "remote", "browser", "warm", "exa", "web-search", ], diff --git a/src/albedo/harness/extensions/direnv/albedo_direnv.erl b/src/albedo/harness/extensions/direnv/albedo_direnv.erl deleted file mode 100644 index 166b8f71..00000000 --- a/src/albedo/harness/extensions/direnv/albedo_direnv.erl +++ /dev/null @@ -1,71 +0,0 @@ -%% The process half of the direnv extension: finds direnv and runs `status` -%% and `allow` with the environment the session's kernel gets, so both read and -%% write the same allow list the kernel's `direnv export` checks. --module(albedo_direnv). - --export([executable/0, status/2, allow/2]). - --define(TIMEOUT_MS, 10000). --define(OUTPUT_CAP, 1048576). - -%% Where a package manager puts direnv when the daemon was started with a thin -%% PATH (a GUI or launchd start never ran the user's profile). Tried after PATH. --define(FALLBACKS, ["/opt/homebrew/bin", "/usr/local/bin", "/run/current-system/sw/bin", - "/nix/var/nix/profiles/default/bin", "~/.nix-profile/bin"]). - -%% {some, Path} for the direnv the daemon would run, or none. -executable() -> - case os:find_executable("direnv") of - false -> fallback(?FALLBACKS); - Path -> {some, unicode:characters_to_binary(Path)} - end. - -fallback([]) -> none; -fallback([Directory | Rest]) -> - Path = filename:join(expand(Directory), "direnv"), - case filelib:is_regular(Path) of - true -> {some, unicode:characters_to_binary(Path)}; - false -> fallback(Rest) - end. - -%% `direnv status --json` run in Directory: its stdout, or why it failed. -status(Exe, Directory) -> - run(Exe, [<<"status">>, <<"--json">>], expand(Directory), []). - -%% `direnv allow Path`: nil, or what direnv said. -allow(Exe, Path) -> - case run(Exe, [<<"allow">>, Path], expand(filename:dirname(Path)), [stderr_to_stdout]) of - {ok, _} -> {ok, nil}; - {error, Reason} -> {error, Reason} - end. - -run(Exe, Args, Directory, Options) -> - try open_port({spawn_executable, binary_to_list(Exe)}, - [{args, [binary_to_list(A) || A <- Args]}, {cd, Directory}, - {env, albedo_python:clean_environment()}, - exit_status, binary, hide | Options]) of - Port -> drain(Port, [], 0, erlang:monotonic_time(millisecond) + ?TIMEOUT_MS) - catch - error:Reason -> {error, unicode:characters_to_binary(io_lib:format("~p", [Reason]))} - end. - -drain(Port, Acc, Size, Deadline) -> - Remaining = max(0, Deadline - erlang:monotonic_time(millisecond)), - receive - {Port, {data, _}} when Size >= ?OUTPUT_CAP -> drain(Port, Acc, Size, Deadline); - {Port, {data, Data}} -> drain(Port, [Data | Acc], Size + byte_size(Data), Deadline); - {Port, {exit_status, 0}} -> {ok, iolist_to_binary(lists:reverse(Acc))}; - {Port, {exit_status, _}} -> - case string:trim(iolist_to_binary(lists:reverse(Acc))) of - <<>> -> {error, <<"direnv failed">>}; - Said -> {error, Said} - end - after Remaining -> - albedo_usage_core:kill(Port), - {error, <<"direnv did not answer within 10 s">>} - end. - -expand([$~ | Rest]) -> os:getenv("HOME", "/") ++ Rest; -expand(<<"~", Rest/binary>>) -> os:getenv("HOME", "/") ++ binary_to_list(Rest); -expand(Path) when is_binary(Path) -> unicode:characters_to_list(Path); -expand(Path) -> Path. diff --git a/src/albedo/harness/extensions/direnv/extension.gleam b/src/albedo/harness/extensions/direnv/extension.gleam deleted file mode 100644 index 8b78eda0..00000000 --- a/src/albedo/harness/extensions/direnv/extension.gleam +++ /dev/null @@ -1,86 +0,0 @@ -//// Load a workspace's .envrc into its sessions' `run()` jobs once the person -//// says so, the way direnv loads it into their shell. The choice is per -//// workspace (folder.gleam); the kernel's `direnv` plugin brings the job -//// environment up to date before each `run()`. robot-docs/direnv.md. - -import albedo/harness/client_api -import albedo/harness/command -import albedo/harness/extension -import albedo/harness/extensions/direnv/folder.{Load} -import albedo/harness/extensions/direnv/service -import albedo/harness/host -import gleam/http -import gleam/json -import gleam/option.{Some} - -pub fn extension() -> extension.Extension { - extension.Extension( - "direnv", - "Load a workspace's .envrc into run() jobs, once the person allows it.", - ["python", "run"], - [ - extension.CommandPlugin([ - command.resource( - "/direnv", - "Show whether this folder's .envrc loads into run() jobs, or change it.", - [], - ), - ]), - extension.ClientPlugin([ - client_api.Command( - "/direnv", - client_api.Read, - [], - client_api.Operation( - ..client_api.operation_defaults( - "getDirenvFolder", - http.Get, - "/extensions/direnv/folders", - 200, - ), - query: [#("workspace", client_api.Session("/workspace"))], - ), - ), - ]), - extension.ServicePlugin(extension.Service( - fn(_, _) { extension.Admission(extension.DaemonToken, 4096) }, - service.handle, - )), - extension.ManagedPlugin(fn(_, _, workspace) { - Ok( - extension.Managed( - ..extension.empty(), - python_modules: ["direnv"], - routes: [ - #("direnv", fn(context: host.Context, method, _) { - case method { - "direnv.state" -> Ok(state(context, workspace)) - _ -> Error(#("invalid", "unknown direnv operation")) - } - }), - ], - ), - ) - }), - ], - folder.initialise, - ) -} - -/// What the kernel needs before a job starts: the folder, whether to load its -/// .envrc, and the direnv to run. Read per `run()`, so a choice made while the -/// kernel runs applies to its next job. -fn state(context: host.Context, workspace: String) -> json.Json { - let directory = folder.directory(workspace) - json.object([ - #("directory", json.nullable(directory, json.string)), - #( - "load", - json.bool( - directory != option.None - && folder.choice(context.store, workspace) == Some(Load), - ), - ), - #("direnv", json.nullable(folder.executable(), json.string)), - ]) -} diff --git a/src/albedo/harness/extensions/direnv/folder.gleam b/src/albedo/harness/extensions/direnv/folder.gleam deleted file mode 100644 index 0f34da05..00000000 --- a/src/albedo/harness/extensions/direnv/folder.gleam +++ /dev/null @@ -1,168 +0,0 @@ -//// A workspace's .envrc as direnv sees it, and what the person chose for it. -//// The choice is kept per workspace in `direnv_folders`; the file and whether -//// direnv trusts its current content always come from direnv itself. - -import albedo/daemon/store -import albedo/harness/location -import gleam/dynamic/decode -import gleam/json -import gleam/list -import gleam/option.{type Option, None, Some} -import gleam/result -import sqlight - -pub type Choice { - Load - Ignore -} - -pub type Folder { - Folder( - workspace: String, - /// The direnv the daemon and the kernel run; None when there is none. - direnv: Option(String), - /// The file direnv loads for the workspace, found in it or above it. - envrc: Option(String), - /// Whether direnv trusts the file's current content (`direnv allow`). - allowed: Bool, - choice: Option(Choice), - /// Why there is nothing to load here, for a person to read. - problem: Option(String), - ) -} - -pub fn initialise(ledger: store.Store) -> Result(Nil, String) { - store.query(ledger, fn(db) { - store.exec( - db, - "CREATE TABLE IF NOT EXISTS direnv_folders(workspace TEXT PRIMARY KEY,choice TEXT NOT NULL CHECK(choice IN ('load','ignore')));", - ) - }) -} - -pub fn choice(ledger: store.Store, workspace: String) -> Option(Choice) { - store.read( - ledger, - "SELECT choice FROM direnv_folders WHERE workspace=?", - [sqlight.text(workspace)], - decode.at([0], decode.string), - ) - |> result.unwrap([]) - |> list.first - |> result.try(parse_choice) - |> option.from_result -} - -pub fn choose( - ledger: store.Store, - workspace: String, - choice: Choice, -) -> Result(Nil, String) { - store.write( - ledger, - "INSERT INTO direnv_folders(workspace,choice) VALUES(?,?) ON CONFLICT(workspace) DO UPDATE SET choice=excluded.choice", - [sqlight.text(workspace), sqlight.text(choice_name(choice))], - ) -} - -pub fn parse_choice(text: String) -> Result(Choice, Nil) { - case text { - "load" -> Ok(Load) - "ignore" -> Ok(Ignore) - _ -> Error(Nil) - } -} - -pub fn choice_name(choice: Choice) -> String { - case choice { - Load -> "load" - Ignore -> "ignore" - } -} - -/// The local directory a workspace names; None for a remote one. -pub fn directory(workspace: String) -> Option(String) { - case location.parse(workspace) { - Ok(location.Local(path)) -> Some(path) - _ -> None - } -} - -/// Runs `direnv status` in the workspace (a few milliseconds), so call it when -/// a person looks, not per turn. -pub fn observe(ledger: store.Store, workspace: String) -> Folder { - let direnv = executable() - let empty = - Folder(workspace, direnv, None, False, choice(ledger, workspace), None) - case found(workspace, direnv) { - Ok(#(envrc, allowed)) -> - Folder(..empty, envrc: Some(envrc), allowed: allowed) - Error(problem) -> Folder(..empty, problem: Some(problem)) - } -} - -/// The file direnv loads for the workspace and whether it trusts it, or why -/// there is none. -fn found( - workspace: String, - direnv: Option(String), -) -> Result(#(String, Bool), String) { - use path <- result.try(option.to_result( - directory(workspace), - "direnv runs only for folders on this machine", - )) - use direnv <- result.try(option.to_result( - direnv, - "direnv is not installed (not on the daemon's PATH)", - )) - use output <- result.try(status(direnv, path)) - case json.parse(output, found_rc()) { - Ok(Some(#(envrc, allowed))) -> Ok(#(envrc, allowed == 0)) - Ok(None) -> Error("no .envrc in this folder or above it") - Error(_) -> - Error( - "direnv status --json gave no answer; direnv 2.33 or later reads it", - ) - } -} - -/// Whether a client should ask the person now: nothing chosen yet, or the -/// file changed since they let it load, so direnv blocks it again. -pub fn ask(folder: Folder) -> Bool { - case folder.envrc, folder.choice { - None, _ -> False - Some(_), None -> True - Some(_), Some(Load) -> !folder.allowed - Some(_), Some(Ignore) -> False - } -} - -/// Lets direnv load the folder's .envrc as it is now. -pub fn allow(folder: Folder) -> Result(Nil, String) { - case folder.direnv, folder.envrc { - Some(direnv), Some(envrc) -> allow_file(direnv, envrc) - _, _ -> Error(option.unwrap(folder.problem, "no .envrc to load")) - } -} - -/// `state.foundRC` of `direnv status --json`: the file and direnv's trust in -/// it (0 allowed, 1 not yet, 2 denied), or None when there is no file. -fn found_rc() -> decode.Decoder(Option(#(String, Int))) { - decode.at( - ["state", "foundRC"], - decode.optional({ - use path <- decode.field("path", decode.string) - use allowed <- decode.field("allowed", decode.int) - decode.success(#(path, allowed)) - }), - ) -} - -@external(erlang, "albedo_direnv", "executable") -pub fn executable() -> Option(String) - -@external(erlang, "albedo_direnv", "status") -fn status(direnv: String, directory: String) -> Result(String, String) - -@external(erlang, "albedo_direnv", "allow") -fn allow_file(direnv: String, envrc: String) -> Result(Nil, String) diff --git a/src/albedo/harness/extensions/direnv/service.gleam b/src/albedo/harness/extensions/direnv/service.gleam deleted file mode 100644 index bcbc4e33..00000000 --- a/src/albedo/harness/extensions/direnv/service.gleam +++ /dev/null @@ -1,202 +0,0 @@ -//// `/extensions/direnv/folders`: what a workspace's .envrc is and whether its -//// sessions' jobs load it. GET reads it; PUT records the person's choice, and -//// a choice to load first lets direnv trust the file as it is now. - -import albedo/daemon/http_api as api -import albedo/harness/client_api -import albedo/harness/extension -import albedo/harness/extensions/direnv/folder.{type Folder, Ignore, Load} -import albedo/harness/location -import gleam/dynamic/decode -import gleam/http.{Get, Put} -import gleam/http/request -import gleam/http/response -import gleam/json -import gleam/list -import gleam/option.{None, Some} -import gleam/result -import gleam/string -import mist - -pub fn handle( - daemon: extension.Daemon, - path: List(String), - req: request.Request(BitArray), - _live: request.Request(mist.Connection), -) -> response.Response(mist.ResponseData) { - case dispatch(daemon, path, req) { - Ok(response) -> response - Error(error) -> api.fail(error) - } -} - -fn dispatch( - daemon: extension.Daemon, - path: List(String), - req: request.Request(BitArray), -) -> Result(response.Response(mist.ResponseData), api.Failure) { - use events <- result.try(api.wants_events(req)) - use _ <- result.try(case events { - True -> Error(api.Failure(406, "not_acceptable", "direnv uses JSON")) - False -> Ok(Nil) - }) - use query <- result.try(api.parameters(req, ["workspace"])) - use workspace <- result.try( - list.key_find(query, "workspace") - |> result.replace_error(api.invalid("workspace is required")) - |> result.try(workspace), - ) - case req.method, path { - Get, ["folders"] -> - Ok(api.reply( - 200, - representation(folder.observe(daemon.ledger, workspace)), - )) - Put, ["folders"] -> { - use choice <- result.try( - api.body(req, ["choice"], { - use choice <- decode.field("choice", decode.string) - case folder.parse_choice(choice) { - Ok(choice) -> decode.success(choice) - Error(_) -> decode.failure(Ignore, "load or ignore") - } - }), - ) - let observed = folder.observe(daemon.ledger, workspace) - use _ <- result.try(case choice { - Load -> - folder.allow(observed) - |> result.map_error(fn(said) { - case observed.envrc { - None -> api.Failure(409, "no_envrc", said) - Some(_) -> api.Failure(503, "direnv_failed", said) - } - }) - Ignore -> Ok(Nil) - }) - use _ <- result.try( - folder.choose(daemon.ledger, workspace, choice) - |> result.map_error(api.Failure(503, "storage_failed", _)), - ) - Ok(api.reply( - 200, - representation(folder.observe(daemon.ledger, workspace)), - )) - } - _, ["folders"] -> - Error(api.Failure(405, "method_not_allowed", "unsupported direnv method")) - _, _ -> Error(api.Failure(404, "not_found", "direnv resource not found")) - } -} - -fn workspace(text: String) -> Result(String, api.Failure) { - use _ <- result.try(case string.byte_size(text) <= 4096 { - True -> Ok(Nil) - False -> Error(api.invalid("workspace exceeds its byte limit")) - }) - location.parse(text) - |> result.map(location.to_string) - |> result.map_error(api.invalid) -} - -fn representation(folder: Folder) -> json.Json { - json.object([ - #("workspace", json.string(folder.workspace)), - #("envrc", json.nullable(folder.envrc, json.string)), - #("allowed", json.bool(folder.allowed)), - #( - "choice", - json.nullable(option.map(folder.choice, folder.choice_name), json.string), - ), - #("ask", json.bool(folder.ask(folder))), - #("problem", json.nullable(folder.problem, json.string)), - #("page", client_api.page(page(folder))), - ]) -} - -/// What `/direnv` shows: the file, what happens with it, and the two choices. -fn page(folder: Folder) -> client_api.Page { - let #(badge, tone, detail) = case folder.choice, folder.allowed { - Some(Load), True -> #( - "loads", - "active", - "run() jobs in this folder start in its environment", - ) - Some(Load), False -> #( - "changed", - "warning", - "it changed since you allowed it, so jobs start without it until you load it again", - ) - Some(Ignore), _ -> #("ignored", "muted", "jobs start without it") - None, _ -> #( - "not chosen", - "warning", - "jobs start without it until you choose", - ) - } - let summary = case folder.envrc { - Some(_) -> badge - None -> option.unwrap(folder.problem, "no .envrc") - } - client_api.Page( - title: "direnv", - summary: summary, - empty_state: option.unwrap(folder.problem, "no .envrc in this folder"), - glance: None, - actions: case folder.envrc { - Some(envrc) -> [ - choice_action( - folder.workspace, - Load, - "ctrl+t", - Some( - "Allow " - <> envrc - <> " in direnv and load it for every session here?", - ), - ), - choice_action(folder.workspace, Ignore, "ctrl+x", None), - ] - None -> [] - }, - rows: case folder.envrc { - Some(envrc) -> [ - client_api.PageRow( - id: "envrc", - text: envrc, - badge: Some(badge), - tone: tone, - detail: Some(detail), - resource: json.object([]), - ), - ] - None -> [] - }, - ) -} - -fn choice_action( - workspace: String, - choice: folder.Choice, - key: String, - confirmation: option.Option(String), -) -> client_api.Action { - let name = folder.choice_name(choice) - client_api.Action( - id: name, - label: name, - keyboard_hint: key, - confirmation: confirmation, - fields: [], - operation: client_api.Operation( - ..client_api.operation_defaults( - "putDirenvFolder", - Put, - "/extensions/direnv/folders", - 200, - ), - query: [#("workspace", client_api.Literal(json.string(workspace)))], - body: [#("/choice", client_api.Literal(json.string(name)))], - ), - ) -} diff --git a/test/e2e/direnv_test.py b/test/e2e/direnv_test.py index 6b80ecd6..e8f54b49 100644 --- a/test/e2e/direnv_test.py +++ b/test/e2e/direnv_test.py @@ -1,18 +1,17 @@ -"""A workspace's .envrc reaches run() jobs once the person lets it load. - -Before a choice the folder asks and jobs start without it; after "load", -direnv trusts the file and the next job starts in its environment (PATH_add -included, so its tools are found by name); an edit makes direnv block the -file again, the folder asks again, and jobs lose the environment; "ignore" -keeps them without it. Runs the real direnv, with its allow list in the -test's own XDG directories. +"""run() jobs get the .envrc direnv would load for the kernel's folder, and +only when direnv allows it, as in the person's shell. + +A blocked file does not load and the cell says so without telling the model +to allow it; once allowed, the next job starts in its environment (PATH_add +included, so its tools are found by name); an edit blocks it again; leaving +the folder unloads it; a denied file stays quiet. Runs the real direnv, with +its allow list in the test's own XDG directories. """ import json import shutil +import subprocess import unittest -import urllib.error -import urllib.parse from harness import Albedo, Provider, exclusive, python, text @@ -42,19 +41,13 @@ class DirenvTests(unittest.TestCase): self.app.__enter__() self.addCleanup(self.app.__exit__, None, None, None) - def folder(self, workspace, choice=None): - query = urllib.parse.urlencode({"workspace": str(workspace)}) - body = None if choice is None else {"choice": choice} - method = None if choice is None else "PUT" - with self.app.api( - f"/extensions/direnv/folders?{query}", body, method=method - ) as response: - return json.load(response) - - def job(self, session, program): - self.cells.append( - f"job = run({program!r})\nawait job\nprint(job.tail().strip())" + def direnv(self, *args): + subprocess.run( + ["direnv", *args], env=self.app.env, check=True, capture_output=True ) + + def cell(self, session, code): + self.cells.append(code) self.app.prompt(session, "run it").close() self.app.idle(session) results = [ @@ -67,53 +60,51 @@ class DirenvTests(unittest.TestCase): self.assertEqual(results[-1]["status"], "ok", results[-1]) return results[-1]["output"].strip() - def test_jobs_load_the_envrc_only_while_the_person_lets_them(self): + def job(self, session, program): + return self.cell( + session, f"job = run({program!r})\nawait job\nprint(job.tail().strip())" + ) + + def test_jobs_load_the_envrc_direnv_allows(self): workspace = self.app.root / "project" (workspace / "bin").mkdir(parents=True) - (workspace / ".envrc").write_text(ENVRC) + envrc = workspace / ".envrc" + envrc.write_text(ENVRC) probe = workspace / "bin" / "probe" probe.write_text(PROBE) probe.chmod(0o755) session = self.app.session(workspace) - asked = self.folder(workspace) - self.assertEqual(asked["envrc"], str(workspace / ".envrc")) - self.assertEqual( - (asked["ask"], asked["choice"], asked["allowed"]), (True, None, False) - ) - self.assertEqual(self.job(session, str(probe)), "probe:unset") + output = self.job(session, str(probe)) + self.assertIn("is not allowed, so jobs start without it", output) + self.assertNotIn("direnv allow", output) + self.assertTrue(output.endswith("probe:unset"), output) - loaded = self.folder(workspace, "load") - self.assertEqual( - (loaded["ask"], loaded["choice"], loaded["allowed"]), (False, "load", True) - ) + self.direnv("allow", str(envrc)) output = self.job(session, "probe") self.assertIn("direnv: loading", output) self.assertTrue(output.endswith("probe:from-envrc"), output) # unchanged, the environment stays and direnv has nothing to say self.assertEqual(self.job(session, "probe"), "probe:from-envrc") - (workspace / ".envrc").write_text(ENVRC + "export SECOND=1\n") - self.assertEqual(self.folder(workspace)["ask"], True) + envrc.write_text(ENVRC + "export SECOND=1\n") output = self.job(session, str(probe)) - self.assertIn("changed since it was allowed", output) - self.assertNotIn("direnv allow", output) + self.assertIn("is not allowed", output) self.assertTrue(output.endswith("probe:unset"), output) - ignored = self.folder(workspace, "ignore") - self.assertEqual((ignored["ask"], ignored["choice"]), (False, "ignore")) - self.assertEqual( - self.job(session, str(probe)), "direnv: unloading\nprobe:unset" + self.direnv("allow", str(envrc)) + self.assertTrue(self.job(session, "probe").endswith("probe:from-envrc")) + elsewhere = self.app.root / "elsewhere" + elsewhere.mkdir() + output = self.cell( + session, + f"os.chdir({str(elsewhere)!r})\njob = run({str(probe)!r})\nawait job\nprint(job.tail().strip())", + ) + self.assertEqual(output, "direnv: unloading\nprobe:unset") + + self.direnv("deny", str(envrc)) + output = self.cell( + session, + f"os.chdir({str(workspace)!r})\njob = run({str(probe)!r})\nawait job\nprint(job.tail().strip())", ) - self.assertEqual(self.job(session, str(probe)), "probe:unset") - - def test_a_folder_without_an_envrc_has_nothing_to_load(self): - workspace = self.app.root / "plain" - workspace.mkdir() - plain = self.folder(workspace) - self.assertEqual((plain["envrc"], plain["ask"]), (None, False)) - self.assertIsNotNone(plain["problem"]) - with self.assertRaises(urllib.error.HTTPError) as refused: - self.folder(workspace, "load") - self.assertEqual(refused.exception.code, 409) - self.assertEqual(json.load(refused.exception)["code"], "no_envrc") + self.assertEqual(output, "probe:unset") diff --git a/web/src/lib/api.gen.d.ts b/web/src/lib/api.gen.d.ts index 76f201f8..1a524ecb 100644 --- a/web/src/lib/api.gen.d.ts +++ b/web/src/lib/api.gen.d.ts @@ -1001,30 +1001,6 @@ export interface paths { patch?: never; trace?: never; }; - "/extensions/direnv/folders": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Read a workspace's .envrc and whether its jobs load it - * @description Runs `direnv status` in a local workspace. `ask` is true while nothing is chosen, or when the file changed since the person chose to load it. A remote workspace or a missing direnv answers with `problem` and no file. - */ - get: operations["getDirenvFolder"]; - /** - * Choose whether a workspace's jobs load its .envrc - * @description Human-only. `load` runs `direnv allow` on the file as it is now, then records the choice; `ignore` only records it. Either applies to every session in the workspace from its next `run()`. Repeating a choice is harmless. - */ - put: operations["putDirenvFolder"]; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/extensions/webhooks/hooks": { parameters: { query?: never; @@ -4367,41 +4343,6 @@ export interface components { }[]; next: components["schemas"]["PageToken"] | null; }; - DirenvChoice: { - /** @enum {string} */ - choice: "load" | "ignore"; - }; - /** - * @example { - * "workspace": "/Users/dawn/proj", - * "envrc": "/Users/dawn/proj/.envrc", - * "allowed": false, - * "choice": null, - * "ask": true, - * "problem": null, - * "page": { - * "title": "direnv", - * "summary": "not chosen", - * "empty_state": "no .envrc in this folder", - * "glance": null, - * "actions": [], - * "rows": [] - * } - * } - */ - DirenvFolder: { - workspace: components["schemas"]["Workspace"]; - /** @description The file direnv loads for the workspace, in it or above it. */ - envrc: string | null; - /** @description Whether direnv trusts the file's current content. */ - allowed: boolean; - choice: ("load" | "ignore") | null; - /** @description Whether a client should ask the person now. */ - ask: boolean; - /** @description Why nothing can load here, for a person to read. */ - problem: string | null; - page: components["schemas"]["PageDescriptor"]; - }; LinkMerge: { other_workspace: components["schemas"]["Workspace"]; other_etag: components["schemas"]["ETag"]; @@ -7493,66 +7434,6 @@ export interface operations { 503: components["responses"]["Problem503"]; }; }; - getDirenvFolder: { - parameters: { - query: { - workspace: components["schemas"]["Workspace"]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description The folder as direnv sees it, and the person's choice. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DirenvFolder"]; - }; - }; - 400: components["responses"]["Problem400"]; - 401: components["responses"]["Problem401"]; - 405: components["responses"]["Problem405"]; - 406: components["responses"]["Problem406"]; - }; - }; - putDirenvFolder: { - parameters: { - query: { - workspace: components["schemas"]["Workspace"]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["DirenvChoice"]; - }; - }; - responses: { - /** @description The folder after the choice. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DirenvFolder"]; - }; - }; - 400: components["responses"]["Problem400"]; - 401: components["responses"]["Problem401"]; - 405: components["responses"]["Problem405"]; - 406: components["responses"]["Problem406"]; - 409: components["responses"]["Problem409"]; - 413: components["responses"]["Problem413"]; - 415: components["responses"]["Problem415"]; - 503: components["responses"]["Problem503"]; - }; - }; listHooks: { parameters: { query?: {