| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "net/http" |
| 6 | "testing" |
| 7 | ) |
| 8 | |
| 9 | func TestRemotePresentedFileDeclaredRequiresTrustedPresentRecord(t *testing.T) { |
| 10 | history := json.RawMessage(`[ |
| 11 | {"role":"tool","toolCallId":"present-1","toolName":"present","presentedFiles":[{"path":"dist/game.html","description":"game"}]}, |
| 12 | {"role":"tool","toolCallId":"plugin-1","toolName":"plugin.present","presentedFiles":[{"path":"dist/fake.html"}]}, |
| 13 | {"role":"assistant","toolCallId":"present-2","toolName":"present","presentedFiles":[{"path":"dist/fake-2.html"}]} |
| 14 | ]`) |
| 15 | if !remotePresentedFileDeclared(history, "present-1", "dist/game.html") { |
| 16 | t.Fatal("trusted present record was not recognized") |
| 17 | } |
| 18 | for _, test := range []struct{ call, path string }{ |
| 19 | {"present-1", "dist/other.html"}, |
| 20 | {"plugin-1", "dist/fake.html"}, |
| 21 | {"present-2", "dist/fake-2.html"}, |
| 22 | {"", "dist/game.html"}, |
| 23 | } { |
| 24 | if remotePresentedFileDeclared(history, test.call, test.path) { |
| 25 | t.Fatalf("untrusted remote record accepted: call=%q path=%q", test.call, test.path) |
| 26 | } |
| 27 | } |
| 28 | if remotePresentedFileDeclared(json.RawMessage(`not-json`), "present-1", "dist/game.html") { |
| 29 | t.Fatal("invalid history must fail closed") |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | func TestRemoteWorkspaceArtifactDeclaredRequiresSuccessfulNativeMutation(t *testing.T) { |
| 34 | history := json.RawMessage(`[ |
| 35 | {"role":"assistant","toolCalls":[ |
| 36 | {"id":"write-1","name":"write_file","arguments":"{\"path\":\"dist/report.md\"}"}, |
| 37 | {"id":"move-1","name":"move_file","arguments":"{\"source_path\":\"a.md\",\"destination_path\":\"dist/moved.md\"}"}, |
| 38 | {"id":"plugin-1","name":"plugin_write","arguments":"{\"path\":\"dist/fake.md\"}"} |
| 39 | ]}, |
| 40 | {"role":"tool","toolCallId":"write-1","toolName":"write_file","content":"wrote dist/report.md"}, |
| 41 | {"role":"tool","toolCallId":"move-1","toolName":"move_file","toolResultError":"denied"}, |
| 42 | {"role":"tool","toolCallId":"plugin-1","toolName":"plugin_write","content":"ok"} |
| 43 | ]`) |
| 44 | if !remoteWorkspaceArtifactDeclared(history, "write-1", "dist/report.md") { |
| 45 | t.Fatal("successful native mutation was not recognized") |
| 46 | } |
| 47 | for _, test := range []struct{ call, path string }{ |
| 48 | {"write-1", "dist/other.md"}, |
| 49 | {"move-1", "dist/moved.md"}, |
| 50 | {"plugin-1", "dist/fake.md"}, |
| 51 | {"", "dist/report.md"}, |
| 52 | } { |
| 53 | if remoteWorkspaceArtifactDeclared(history, test.call, test.path) { |
| 54 | t.Fatalf("untrusted remote artifact accepted: call=%q path=%q", test.call, test.path) |
| 55 | } |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | func TestRemotePresentedFilesFenceRejectsRouteAndConnectionChanges(t *testing.T) { |
| 60 | client := &http.Client{} |
| 61 | tab := &remoteTab{ |
| 62 | id: "remote-present", |
| 63 | ref: RemoteTabRef{HostID: "host-a"}, |
| 64 | state: "ready", |
| 65 | client: client, |
| 66 | base: "http://127.0.0.1:43120", |
| 67 | gen: 7, |
| 68 | selectionRevision: 3, |
| 69 | capabilities: map[string]bool{"present-files-v1": true}, |
| 70 | routing: remoteTabSessionRouting{ |
| 71 | currentPath: "/remote/workspace/.reasonix/sessions/one.jsonl", |
| 72 | }, |
| 73 | } |
| 74 | app := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}} |
| 75 | fence, err := app.requireRemotePresentedFiles(tab.id, "host-a") |
| 76 | if err != nil { |
| 77 | t.Fatalf("require remote present support: %v", err) |
| 78 | } |
| 79 | if !app.remotePresentedFilesFenceCurrent(fence) { |
| 80 | t.Fatal("fresh remote present fence should be current") |
| 81 | } |
| 82 | |
| 83 | for _, mutate := range []func(){ |
| 84 | func() { tab.gen++ }, |
| 85 | func() { tab.selectionRevision++ }, |
| 86 | func() { tab.client = &http.Client{} }, |
| 87 | func() { tab.routing.currentPath = "/remote/workspace/.reasonix/sessions/two.jsonl" }, |
| 88 | func() { tab.routing.rehydratingPath = tab.routing.currentPath }, |
| 89 | } { |
| 90 | mutate() |
| 91 | if app.remotePresentedFilesFenceCurrent(fence) { |
| 92 | t.Fatal("stale remote present fence was accepted") |
| 93 | } |
| 94 | tab.client = client |
| 95 | tab.gen = fence.generation |
| 96 | tab.selectionRevision = fence.selectionRevision |
| 97 | tab.routing.currentPath = fence.sessionPath |
| 98 | tab.routing.rehydratingPath = "" |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | func TestRemotePresentedAbsolutePathUsesSourceWorkspaceStyle(t *testing.T) { |
| 103 | for _, test := range []struct{ workspace, presented, want string }{ |
| 104 | {"/srv/project", "dist/game.html", "/srv/project/dist/game.html"}, |
| 105 | {"/srv/project", "/tmp/report.pdf", "/tmp/report.pdf"}, |
| 106 | {`C:\work\project`, `dist\game.html`, `C:\work\project\dist\game.html`}, |
| 107 | {`C:\work\project`, `D:\out\report.pdf`, `D:\out\report.pdf`}, |
| 108 | } { |
| 109 | if got := remotePresentedAbsolutePath(test.workspace, test.presented); got != test.want { |
| 110 | t.Errorf("remotePresentedAbsolutePath(%q, %q) = %q, want %q", test.workspace, test.presented, got, test.want) |
| 111 | } |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | func TestConfinedRemoteWorkspacePath(t *testing.T) { |
| 116 | for _, test := range []struct { |
| 117 | workspace string |
| 118 | requested string |
| 119 | want string |
| 120 | allowed bool |
| 121 | }{ |
| 122 | {"/srv/project", "dist/report.md", "/srv/project/dist/report.md", true}, |
| 123 | {"/srv/project", "/srv/project/dist/report.md", "/srv/project/dist/report.md", true}, |
| 124 | {"/srv/project", "../secret.txt", "", false}, |
| 125 | {"/srv/project", "/srv/project-old/secret.txt", "", false}, |
| 126 | {`C:\work\project`, `dist\report.md`, `C:\work\project\dist\report.md`, true}, |
| 127 | {`C:\work\project`, `C:\work\project\dist\report.md`, `C:\work\project\dist\report.md`, true}, |
| 128 | {`C:\work\project`, `D:\secret.txt`, "", false}, |
| 129 | } { |
| 130 | got, err := confinedRemoteWorkspacePath(test.workspace, test.requested) |
| 131 | if test.allowed && (err != nil || got != test.want) { |
| 132 | t.Errorf("confinedRemoteWorkspacePath(%q, %q) = %q, %v; want %q", test.workspace, test.requested, got, err, test.want) |
| 133 | } |
| 134 | if !test.allowed && err == nil { |
| 135 | t.Errorf("confinedRemoteWorkspacePath(%q, %q) unexpectedly allowed %q", test.workspace, test.requested, got) |
| 136 | } |
| 137 | } |
| 138 | } |
| 139 |