From 02dbde77bdb08f3d0cced01ad00586de0e6254a0 Mon Sep 17 00:00:00 2001 From: Hongyi Shen Date: Thu, 15 Oct 2026 12:19:52 -0700 Subject: [PATCH] devin: import web sessions through fork --from Signed-off-by: Hongyi Shen --- README.md | 22 ++++ internal/cli/cli.go | 20 +++- internal/cli/cli_test.go | 151 ++++++++++++++++++++++++++ internal/devin/devin.go | 154 +++++++++++++++++++++++++++ internal/devin/testdata/events.jsonl | 13 +++ 5 files changed, 355 insertions(+), 5 deletions(-) create mode 100644 internal/devin/devin.go create mode 100644 internal/devin/testdata/events.jsonl diff --git a/README.md b/README.md index 6f678e6..65efa08 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,28 @@ Amp reads local JSON caches in `$XDG_DATA_HOME/amp/threads` (default `~/.local/s Set `AMP_DATA_HOME` to override the Amp data directory. Cloud-only threads are unavailable until cached locally. Amp tool results and reasoning are omitted. Native Amp launch and resume are unsupported. +### Devin web sessions + +Create an **organization-scoped service-user API key** in Devin's **Settings > Devin API**. +The key needs read access to the session. Set `DEVIN_API_KEY`, then paste the session URL: + +```bash +export DEVIN_API_KEY='your-api-key' +catchup fork --into codex --from 'https://app.devin.ai/sessions/YOUR_SESSION_ID' +``` + +Enterprise URLs such as `https://your-team.devinenterprise.com/sessions/YOUR_SESSION_ID` +also work. Catchup discovers the organization from the key and imports the conversation +and failed shell commands. It sends the key only to `api.devin.ai` and does not store it. +Personal access tokens and enterprise-scoped keys need explicit organization selection, +which this importer does not implement. + +The importer uses the `v3beta1` event endpoint behind [Devin MCP](https://docs.devin.ai/work-with-devin/devin-mcp). +That endpoint is absent from the public v3 OpenAPI specification and may change. +Shell output can be truncated; warnings also identify event fields truncated by the API. +Attachments, non-shell tool failures, and compaction history are unavailable. +Devin URL imports use `fork --from`; local discovery, search, and native Devin resume do not apply. + ## Boundaries - One agent at a time. It does not merge histories. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3346f19..608c187 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -30,6 +30,7 @@ import ( "github.com/wilbeibi/catchup/internal/copilot" "github.com/wilbeibi/catchup/internal/cursor" "github.com/wilbeibi/catchup/internal/deepseek" + "github.com/wilbeibi/catchup/internal/devin" "github.com/wilbeibi/catchup/internal/grok" "github.com/wilbeibi/catchup/internal/grokbot" "github.com/wilbeibi/catchup/internal/kimi" @@ -74,6 +75,7 @@ HAND OFF — continue the work --into seed a different agent with the transcript; the same agent, with --last/--since-compact, restarts it clean --from with --into: seed from a file, - (stdin), or http(s) URL + Devin session URLs require DEVIN_API_KEY (org service key) --model launch it on a specific model, e.g. a cheaper one (the target agent's own model name, passed as-is) @@ -755,9 +757,9 @@ func forkInto(ctx context.Context, src session.Source, cmd Command, launchDir st // forkFrom is the artifact half of fork: it seeds the --into // agent from a document that did not come from a provider store — a file, -// stdin, or a plain-GET URL. The artifact is seeded verbatim (any text -// document works, not only catchup output), so no Thread is built and the -// trims cannot apply; parse.go rejects those combinations. Same-agent --into +// stdin, or a URL. Devin URLs are exported through its API before seeding; +// other artifacts are seeded verbatim. The trims cannot apply to arbitrary +// documents; parse.go rejects those combinations. Same-agent --into // is allowed by construction: no native state stands behind an artifact, so // the seed is the best continuation there is. func forkFrom(ctx context.Context, cmd Command, launchDir string, stdin io.Reader, stdout, stderr io.Writer) error { @@ -821,8 +823,7 @@ func fromLabel(from string) string { return from } -// readArtifact fetches the --from artifact's bytes. The spelling was -// validated at parse time; the three shapes just dispatch here. +// readArtifact loads --from bytes, rendering Devin's API events as a transcript. func readArtifact(ctx context.Context, from string, stdin io.Reader) ([]byte, error) { switch { case from == "-": @@ -835,6 +836,15 @@ func readArtifact(ctx context.Context, from string, stdin io.Reader) ([]byte, er } return b, nil case isHTTPURL(from): + if devin.IsURL(from) { + thread, err := devin.Read(ctx, from, os.Getenv("DEVIN_API_KEY"), artifactClient) + if err != nil { + return nil, err + } + var buf bytes.Buffer + err = render.Thread(&buf, thread, session.FormatAgent) + return buf.Bytes(), err + } return fetchArtifact(ctx, from) default: b, err := os.ReadFile(expandTilde(from)) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 9bfa3d4..8dddd05 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "regexp" @@ -1429,7 +1430,11 @@ func TestRunForkFromStdinNoTTY(t *testing.T) { } func TestRunForkFromURL(t *testing.T) { + t.Setenv("DEVIN_API_KEY", "unrelated-secret") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Basic bGFiZWwtdXNlcjpsYWJlbC1wYXNzd29yZA==" { + t.Error("generic URL authorization changed") + } fmt.Fprint(w, "remote transcript body") })) defer srv.Close() @@ -1479,6 +1484,152 @@ func TestRunForkFromURL(t *testing.T) { } } +type devinTransport func(*http.Request) (*http.Response, error) + +func (f devinTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestRunForkFromDevin(t *testing.T) { + // Selected real events from Devin MCP 2.14.3's v3beta1 HTTP endpoint, + // observed 2026-10-15. IDs/text are sanitized; order and wire keys remain. + // Padding, duplicate completion and truncation markers exercise boundaries. + fixture, err := os.ReadFile("../devin/testdata/events.jsonl") + if err != nil { + t.Fatal(err) + } + rows := bytes.Split(bytes.TrimSpace(fixture), []byte("\n")) + first := make([]json.RawMessage, 0, 50) + for _, row := range rows[:11] { + first = append(first, json.RawMessage(row)) + } + for len(first) < 50 { + first = append(first, json.RawMessage(rows[2])) // ignored reasoning events + } + last := []json.RawMessage{rows[11], bytes.ReplaceAll(rows[11], []byte("shell_process_completed"), []byte("shell_process_completed_background")), rows[12]} + last[2] = bytes.Replace(last[2], []byte(`"truncated_fields":[]`), []byte(`"truncated_fields":["message"]`), 1) + for _, tc := range []struct { + name, from, key, fault, wantErr string + }{ + {"app", "https://app.devin.ai/sessions/abc?token=url-secret#fragment-secret", "test-key", "", ""}, + {"enterprise", "https://team.devinenterprise.com/sessions/devin-abc/", "test-key", "", ""}, + {"attachment", "https://app.devin.ai/attachments/note.txt", "test-key", "artifact", ""}, + {"missing key", "https://app.devin.ai/sessions/abc", "", "", "DEVIN_API_KEY"}, + {"wrong path", "https://app.devin.ai/sessions/abc/extra", "test-key", "", "session URL"}, + {"http", "http://app.devin.ai/sessions/abc", "test-key", "", "HTTPS"}, + {"unauthorized", "https://app.devin.ai/sessions/abc", "test-key", "auth", "HTTP 401"}, + {"unscoped key", "https://app.devin.ai/sessions/abc", "test-key", "org", "organization-scoped"}, + {"redirect", "https://app.devin.ai/sessions/abc", "test-key", "redirect", "HTTP 302"}, + {"second page fails", "https://app.devin.ai/sessions/abc", "test-key", "page", "HTTP 403"}, + {"schema drift", "https://app.devin.ai/sessions/abc", "test-key", "schema", "no items"}, + {"invalid JSON", "https://app.devin.ai/sessions/abc", "test-key", "json", "decode API response"}, + {"empty session", "https://app.devin.ai/sessions/abc", "test-key", "empty", "no conversation"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("DEVIN_API_KEY", tc.key) + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if tc.fault == "artifact" { + if r.Header.Get("Authorization") != "" { + t.Error("API key sent to attachment URL") + } + fmt.Fprint(w, "remote transcript body") + return + } + if r.Method != "GET" || r.Header.Get("Authorization") != "Bearer test-key" { + t.Errorf("unexpected method or authorization: %s", r.Method) + } + if tc.fault == "auth" { + http.Error(w, "test-key must not reach error text", 401) + return + } + if tc.fault == "redirect" { + http.Redirect(w, r, "https://elsewhere.invalid/leak", 302) + return + } + if r.URL.Path == "/v3/self" { + if tc.fault == "org" { + fmt.Fprint(w, `{}`) + } else { + fmt.Fprint(w, `{"org_id":"org-example"}`) + } + return + } + if r.URL.Path != "/v3beta1/organizations/org-example/sessions/devin-abc/events/details" || r.URL.Query().Get("limit") != "50" || r.URL.Query().Get("max_content_length") != "100000" { + t.Errorf("unexpected API request: %s", r.URL) + } + switch tc.fault { + case "schema": + fmt.Fprint(w, `{}`) + return + case "json": + fmt.Fprint(w, `{`) + return + case "empty": + fmt.Fprint(w, `{"items":[]}`) + return + } + page := first + switch r.URL.Query().Get("offset") { + case "0": + case "50": + if tc.fault == "page" { + http.Error(w, "forbidden", 403) + return + } + page = last + default: + t.Error("incorrect pagination offset") + page = nil + } + json.NewEncoder(w).Encode(map[string]any{"items": page}) + })) + defer srv.Close() + base, _ := url.Parse(srv.URL) + old := artifactClient + t.Cleanup(func() { artifactClient = old }) + artifactClient = &http.Client{Transport: devinTransport(func(r *http.Request) (*http.Response, error) { + if r.URL.Host != "api.devin.ai" && !(tc.fault == "artifact" && r.URL.Host == "app.devin.ai") { + t.Errorf("credential sent to unexpected host: %s", r.URL.Host) + } + r.URL.Scheme, r.URL.Host = base.Scheme, base.Host + return srv.Client().Transport.RoundTrip(r) + })} + name, prompt, _, err := forkFromRun(t, nil, "fork", "--into", "codex", "--from", tc.from) + if tc.fault == "artifact" { + if err != nil || name != "codex" || requests != 1 || !strings.Contains(prompt, "remote transcript body") { + t.Fatalf("attachment import changed: agent=%q requests=%d err=%v", name, requests, err) + } + return + } + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) || name != "" || strings.Contains(err.Error(), "test-key") { + t.Fatalf("want %q without launch or credential leak, got agent=%q err=%v", tc.wantErr, name, err) + } + return + } + if err != nil || name != "codex" || requests != 3 { + t.Fatalf("agent=%q requests=%d err=%v", name, requests, err) + } + previous := -1 + for _, text := range []string{"Check the cache.", "I will inspect it.", "Keep the public API unchanged.", "I will run the tests.", "go test ./...", "FAIL: configuration missing", "The tests need a configuration fix."} { + index := strings.Index(prompt, text) + if index <= previous { + t.Fatalf("missing or reordered %q in transcript", text) + } + previous = index + } + if strings.Count(prompt, "failure: shell") != 1 || !strings.Contains(prompt, "Devin truncated devin_message fields: message") || !strings.Contains(prompt, "compaction history") { + t.Fatalf("missing failure deduplication or loss warnings: %s", prompt) + } + for _, excluded := range []string{"test-key", "url-secret", "fragment-secret", "private reasoning", "successful output"} { + if strings.Contains(prompt, excluded) { + t.Errorf("unexpected %q in transcript", excluded) + } + } + }) + } +} + func TestRunForkFromEmptyArtifact(t *testing.T) { path := filepath.Join(t.TempDir(), "empty.md") if err := os.WriteFile(path, []byte(" \n\t\n"), 0o644); err != nil { diff --git a/internal/devin/devin.go b/internal/devin/devin.go new file mode 100644 index 0000000..6d367d6 --- /dev/null +++ b/internal/devin/devin.go @@ -0,0 +1,154 @@ +// Package devin imports cloud sessions through the HTTP API used by Devin MCP. +// Evidence: live /v3/self and /v3beta1/.../events/details responses, 2026-10-15. +// The beta event endpoint is not in the public v3 OpenAPI specification. +package devin + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/wilbeibi/catchup/internal/session" +) + +var sessionPath = regexp.MustCompile(`^/sessions/((?:devin-)?[A-Za-z0-9_-]+)/?$`) + +// IsURL recognizes Devin session links, including malformed paths that Read +// must reject instead of handing the receiving agent a login page. +func IsURL(raw string) bool { + u, err := url.Parse(raw) + return err == nil && (u.Hostname() == "app.devin.ai" || strings.HasSuffix(u.Hostname(), ".devinenterprise.com")) && + (u.Path == "/sessions" || strings.HasPrefix(u.Path, "/sessions/")) +} + +// Read retrieves a session in timeline order. The supplied client owns the +// transport; credentials only go to api.devin.ai, never the pasted URL. +func Read(ctx context.Context, raw, key string, client *http.Client) (session.Thread, error) { + t := session.Thread{} + u, err := url.Parse(raw) + if err != nil || !IsURL(raw) || u.Scheme != "https" || u.Port() != "" || !sessionPath.MatchString(u.Path) { + return t, errors.New("devin: paste an HTTPS session URL from app.devin.ai or your Devin Enterprise site") + } + if strings.TrimSpace(key) == "" { + return t, errors.New("devin: set DEVIN_API_KEY to an organization-scoped service-user API key") + } + id := strings.TrimPrefix(sessionPath.FindStringSubmatch(u.Path)[1], "devin-") + u.User, u.RawQuery, u.Fragment, u.RawFragment, u.ForceQuery = nil, "", "", "", false + t.Source = session.Source{Ref: session.Ref{Provider: "devin", SessionID: id}, Path: u.String(), Metadata: map[string]string{"url": u.String()}} + t.Warnings = []string{"Devin shell output may be truncated; non-shell tool failures, attachments and compaction history are unavailable"} + ctx, cancel := context.WithTimeout(ctx, time.Minute) + defer cancel() + httpClient := *client + httpClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + get := func(path string, dst any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.devin.ai"+path, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+key) + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("devin: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("devin: API returned HTTP %d; check DEVIN_API_KEY and session access", resp.StatusCode) + } + if err := json.NewDecoder(resp.Body).Decode(dst); err != nil { + return fmt.Errorf("devin: decode API response: %w", err) + } + return nil + } + var self struct { + OrgID string `json:"org_id"` + } + if err := get("/v3/self", &self); err != nil { + return t, err + } + if self.OrgID == "" { + return t, errors.New("devin: DEVIN_API_KEY must be an organization-scoped service-user key; this key has no org_id") + } + commands := make(map[string]string) + failed := make(map[string]bool) + var unknown session.UnknownTypes + for offset := 0; ; offset += 50 { + var page struct { + Items []struct { + Type string `json:"event_type"` + Category string `json:"category"` + CreatedAt int64 `json:"created_at"` + Contents json.RawMessage `json:"contents"` + Truncated []string `json:"truncated_fields"` + } `json:"items"` + } + path := fmt.Sprintf("/v3beta1/organizations/%s/sessions/devin-%s/events/details?offset=%d&limit=50&max_content_length=100000", url.PathEscape(self.OrgID), id, offset) + if err := get(path, &page); err != nil { + return t, err + } + if page.Items == nil { + return t, errors.New("devin: event response has no items; the beta API may have changed") + } + for _, e := range page.Items { + switch e.Type { + case "initialized", "initial_user_message", "user_message", "devin_message", "shell_process_started", "shell_process_completed", "shell_process_completed_background": + default: + if e.Category == "message" { + unknown.Add(e.Type) + } + continue + } + var c struct { + Title, Message, Command string + ProcessID string `json:"process_id"` + ExitCode string `json:"exit_code"` + Output string `json:"output_trunc"` + } + if err := json.Unmarshal(e.Contents, &c); err != nil { + return t, fmt.Errorf("devin: decode %s: %w", e.Type, err) + } + if len(e.Truncated) > 0 { + t.Warnings = append(t.Warnings, "Devin truncated "+e.Type+" fields: "+strings.Join(e.Truncated, ", ")) + } + at := time.Unix(e.CreatedAt, 0) + t.Source.UpdatedAt = at + switch e.Type { + case "initialized": + t.Source.Metadata["title"] = c.Title + case "initial_user_message", "user_message", "devin_message": + role := session.RoleUser + if e.Type == "devin_message" { + role = session.RoleAssistant + } + t.Entries = append(t.Entries, session.Entry{Kind: session.KindMessage, Role: role, Text: c.Message, Time: at}) + case "shell_process_started": + if c.ProcessID == "" { + return t, errors.New("devin: shell start has no process_id") + } + commands[c.ProcessID] = c.Command + default: + if c.ProcessID == "" || c.ExitCode == "" { + return t, errors.New("devin: shell completion has no process_id or exit_code") + } + if c.ExitCode != "" && c.ExitCode != "0" && !failed[c.ProcessID] { + input, _ := json.Marshal(commands[c.ProcessID]) + t.Entries = append(t.Entries, session.Failure("shell", input, "exit_code="+c.ExitCode+"\n"+c.Output, at)) + failed[c.ProcessID] = true + } + } + } + if len(page.Items) < 50 { + break + } + } + t.Warnings = unknown.AppendTo(t.Warnings) + if len(t.Entries) == 0 { + return t, errors.New("devin: session has no conversation or failed shell commands to import") + } + return t, nil +} diff --git a/internal/devin/testdata/events.jsonl b/internal/devin/testdata/events.jsonl new file mode 100644 index 0000000..ce38d4a --- /dev/null +++ b/internal/devin/testdata/events.jsonl @@ -0,0 +1,13 @@ +{"event_id":"event-001","event_type":"initial_user_message","category":"message","direction":"incoming","created_at":1791554912,"truncated_fields":[],"contents":{"type":"initial_user_message","message":"Check the cache.","timestamp":"2026-10-09T14:08:32.924424+00:00"}} +{"event_id":"event-002","event_type":"initialized","category":"status","direction":"outgoing","created_at":1791554914,"truncated_fields":[],"contents":{"type":"initialized","title":"Check cache configuration","timestamp":"2026-10-09T14:08:34.571000Z"}} +{"event_id":"event-003","event_type":"devin_thoughts","category":"other","direction":"outgoing","created_at":1791554923,"truncated_fields":[],"contents":{"type":"devin_thoughts","message":"private reasoning","timestamp":"2026-10-09T14:08:40.389000Z"}} +{"event_id":"event-004","event_type":"devin_message","category":"message","direction":"outgoing","created_at":1791554923,"truncated_fields":[],"contents":{"type":"devin_message","message":"I will inspect it.","timestamp":"2026-10-09T14:08:40.389000Z"}} +{"event_id":"event-005","event_type":"user_message","category":"message","direction":"incoming","created_at":1791555525,"truncated_fields":[],"contents":{"type":"user_message","message":"Keep the public API unchanged.","timestamp":"2026-10-09T14:18:45.418816+00:00"}} +{"event_id":"event-006","event_type":"devin_message","category":"message","direction":"outgoing","created_at":1791555533,"truncated_fields":[],"contents":{"type":"devin_message","message":"I will run the tests.","timestamp":"2026-10-09T14:18:52.858000Z"}} +{"event_id":"event-007","event_type":"shell_process_started","category":"shell","direction":"outgoing","created_at":1791555533,"truncated_fields":[],"contents":{"type":"shell_process_started","command":"ls","shell_id":"shell-process-ok","timestamp":"2026-10-09T14:18:52.955000Z","process_id":"process-ok"}} +{"event_id":"event-008","event_type":"terminal_update","category":"shell","direction":"outgoing","created_at":1791555533,"truncated_fields":[],"contents":{"type":"terminal_update","shell_id":"shell-process-ok","timestamp":"2026-10-09T14:18:52.955000Z","process_id":"process-ok"}} +{"event_id":"event-009","event_type":"shell_process_completed","category":"shell","direction":"outgoing","created_at":1791555533,"truncated_fields":[],"contents":{"type":"shell_process_completed","exit_code":"0","timestamp":"2026-10-09T14:18:52.961000Z","process_id":"process-ok","output_trunc":"successful output"}} +{"event_id":"event-010","event_type":"shell_process_started","category":"shell","direction":"outgoing","created_at":1791555627,"truncated_fields":[],"contents":{"type":"shell_process_started","command":"go test ./...","shell_id":"shell-process-failed","timestamp":"2026-10-09T14:20:27.470000Z","process_id":"process-failed"}} +{"event_id":"event-011","event_type":"terminal_update","category":"shell","direction":"outgoing","created_at":1791555628,"truncated_fields":[],"contents":{"type":"terminal_update","shell_id":"shell-process-failed","timestamp":"2026-10-09T14:20:27.470000Z","process_id":"process-failed"}} +{"event_id":"event-012","event_type":"shell_process_completed","category":"shell","direction":"outgoing","created_at":1791555628,"truncated_fields":[],"contents":{"type":"shell_process_completed","exit_code":"1","timestamp":"2026-10-09T14:20:27.789000Z","process_id":"process-failed","output_trunc":"FAIL: configuration missing"}} +{"event_id":"event-013","event_type":"devin_message","category":"message","direction":"outgoing","created_at":1791555876,"truncated_fields":[],"contents":{"type":"devin_message","message":"The tests need a configuration fix.","timestamp":"2026-10-09T14:24:23.035000Z"}}