Add per-task terminals over WebSocket with an xterm.js Terminal tab

This commit is contained in:
Ian Gulliver
2026-10-15 12:06:50 -07:00
parent 37bf1b60a7
commit ff22f33025
33 changed files with 1600 additions and 167 deletions
+3 -3
View File
@@ -293,15 +293,15 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
tool("show",
"Point the human's right pane at a task, or at a project with no task: tabs are the tabs to put first, in order (the rest follow), and tab is the one to select. "+
"Tabs: transcript, changes, files, inbox (the task's open items), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+
"Tabs: transcript, changes, files, terminal (a shell in the task's worktree), inbox (the task's open items), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+
"Omit both task_id and project_id to clear the pane. "+
"If the pane already shows that task (or project) on that tab, only the tab order changes and you are told so, even while the human is working in the pane. "+
"Otherwise refused while the human is working in the right pane, unless it is on the project tab or what it shows is finished (a merged or discarded task, or an inbox tab with nothing left).",
`{"type": "object", "properties": {
"task_id": {"type": "string"},
"project_id": {"type": "string", "description": "Ignored when task_id is given."},
"tabs": {"type": "array", "items": {"type": "string", "enum": ["transcript", "changes", "files", "inbox", "project"]}},
"tab": {"type": "string", "enum": ["transcript", "changes", "files", "inbox", "project"]}
"tabs": {"type": "array", "items": {"type": "string", "enum": ["transcript", "changes", "files", "terminal", "inbox", "project"]}},
"tab": {"type": "string", "enum": ["transcript", "changes", "files", "terminal", "inbox", "project"]}
}, "required": ["tabs", "tab"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
f := api.Focus{}
+222 -143
View File
@@ -327,6 +327,7 @@ const (
TabFiles TaskTab = "files"
TabInbox TaskTab = "inbox"
TabProject TaskTab = "project"
TabTerminal TaskTab = "terminal"
TabTranscript TaskTab = "transcript"
)
@@ -341,6 +342,8 @@ func (e TaskTab) Valid() bool {
return true
case TabProject:
return true
case TabTerminal:
return true
case TabTranscript:
return true
default:
@@ -844,6 +847,12 @@ type TaskCreate struct {
// TaskTab defines model for TaskTab.
type TaskTab string
// TerminalSize defines model for TerminalSize.
type TerminalSize struct {
Cols int `json:"cols"`
Rows int `json:"rows"`
}
// Todo defines model for Todo.
type Todo struct {
// Exists Whether the default branch has a TODO.md at its root.
@@ -984,6 +993,12 @@ type GetTaskImageParams struct {
Path string `form:"path" json:"path"`
}
// GetTaskTerminalParams defines parameters for GetTaskTerminal.
type GetTaskTerminalParams struct {
Cols int `form:"cols" json:"cols"`
Rows int `form:"rows" json:"rows"`
}
// RespondInboxJSONRequestBody defines body for RespondInbox for application/json ContentType.
type RespondInboxJSONRequestBody = InboxRespond
@@ -1155,6 +1170,9 @@ type ServerInterface interface {
// (POST /api/tasks/{taskId}/snapshots/{n}/fork)
ForkSnapshot(w http.ResponseWriter, r *http.Request, taskId string, n int)
// (GET /api/tasks/{taskId}/terminal)
GetTaskTerminal(w http.ResponseWriter, r *http.Request, taskId string, params GetTaskTerminalParams)
// (GET /api/tasks/{taskId}/unmerged)
GetUnmerged(w http.ResponseWriter, r *http.Request, taskId string)
}
@@ -2409,6 +2427,61 @@ func (siw *ServerInterfaceWrapper) ForkSnapshot(w http.ResponseWriter, r *http.R
handler.ServeHTTP(w, r)
}
// GetTaskTerminal operation middleware
func (siw *ServerInterfaceWrapper) GetTaskTerminal(w http.ResponseWriter, r *http.Request) {
var err error
_ = err
// ------------- Path parameter "taskId" -------------
var taskId string
err = runtime.BindStyledParameterWithOptions("simple", "taskId", r.PathValue("taskId"), &taskId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "taskId", Err: err})
return
}
// Parameter object where we will unmarshal all parameters from the context
var params GetTaskTerminalParams
// ------------- Required query parameter "cols" -------------
err = runtime.BindQueryParameterWithOptions("form", true, true, "cols", r.URL.Query(), &params.Cols, runtime.BindQueryParameterOptions{Type: "integer", Format: ""})
if err != nil {
var requiredError *runtime.RequiredParameterError
if errors.As(err, &requiredError) {
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cols"})
} else {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cols", Err: err})
}
return
}
// ------------- Required query parameter "rows" -------------
err = runtime.BindQueryParameterWithOptions("form", true, true, "rows", r.URL.Query(), &params.Rows, runtime.BindQueryParameterOptions{Type: "integer", Format: ""})
if err != nil {
var requiredError *runtime.RequiredParameterError
if errors.As(err, &requiredError) {
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "rows"})
} else {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "rows", Err: err})
}
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetTaskTerminal(w, r, taskId, params)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// GetUnmerged operation middleware
func (siw *ServerInterfaceWrapper) GetUnmerged(w http.ResponseWriter, r *http.Request) {
@@ -2585,6 +2658,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/diff", wrapper.GetTaskDiff)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/dir", wrapper.GetTaskDir)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/file", wrapper.GetTaskFile)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/terminal", wrapper.GetTaskTerminal)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/snapshots", wrapper.ListSnapshots)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/snapshots/{n}/fork", wrapper.ForkSnapshot)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/sessions", wrapper.ListSessions)
@@ -2609,149 +2683,154 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
// const string: with thousands of chunks the chained `+` fold is several
// times slower for the Go compiler than parsing a slice literal.
var swaggerSpec = []string{
"7H1/cxu5seBXQfGuSva9EeVNNql7ct0fXq9345f12s/SvlRdbosBOU0S0RCgAYxoxuXvftXdwAyGgxFH",
"smQ7b/PPrsyZARqN/o3uxofJwmy2RoP2bnL+YWLBbY12QP94Ya2x+MfCaA/a459yu63UQnpl9NnfndH4",
"m1usYSPxr/9pYTk5n/yPs3bUM37qzni0jx8/FpMS3MKqLQ4yOedpphN8EN7FoZ6tQPs31izA0b+31mzB",
"esWgrUFWfo1/dcdyvl5ciQ1I7YQUvrZaKCdsrbXSKyF1KbQRzluQGwHXoL1YSyekteoaSrE0VtAIM7n0",
"YGcOFkaX7qmA98pDGcb1axBbBgwHXxkN00kxAV1vJud/nZirScFw4I/04eTXYuL3WyAArdKrSTF5f4of",
"nF5Lq+UGF/XXyZ9oUa/xO/7zIozC/3oRxvpYTCrp/Izgn0nfx8Jf1qA7YOL7YmeNByFFpTQIb4TyLqBi",
"UkyWxm5wqEkpPZx6tYFJMTFW7HAo5YXz0noop5NzXVfV4XI+FpOtKhGQ8LvSHlZg8YED55TRs87z9jvn",
"pQeiLCeMFrTtF/xJM1l3dZdrEGHQE1wBfp8ByEt3NTTpNeiSSfvg0cdiYuFdrSyUuCMJ7O2Azee86LiE",
"w10pIpG2m2/mf4eFRwDSZfape2FBeijD3ma3prek+M18n13x0tirZsQDalF+LcLzpTWbcyIdXuOJE6oU",
"Zkk/ERVtwDm5AvoBPxJXAFs3zYGUjNmf9Rl9feLEUlkX6EtYcPUGkMWUCxDErS7EovZCetGspBC1LsEK",
"KTTswtuzdseeCjl3yOHI1cavoRkqD+0AqWxMCVUf/LfwrgZHQgFfeCpgs/X7REI0+CthKevKZ+c0Ow22",
"PzhSeGVWSovd2jgQFchSSCQZxoxycT5cmxRImcgKkW0yM1lTEZdFKaU22wo2oD0gHeMEfSGVcmcXwHc1",
"1I08bOXsTiqPcpahWloA4Srjwz4hVhZGL2prQS/2YiGZbhqQygopm8eeFJMgtScF6agKxgvSlLsucAUv",
"eeje7/8Z5+o9edtM3nv0vIWmK2YO1FqyQbSBN21PK5AiNhaVrEvIbkqP1I8LsgMBxnSXCDKm8tzQrXgj",
"EuoImiKVVFkx571crDfBdjgQcjJgKsN0oMsZqqm8RlEbucoQJf18Lkrppfjl7U9ZPF8pXaZYXipaU6mW",
"y0kcGP8pV5bUIqIMUaDl1q0NCvXaVtlN2YDdyBwdhMHOxSt+QzhT2wVkwdvKnFGDQJ4LC1tzaqGSXl2D",
"wDenIixZav5L4JtCse4PQmFn7JW3AAWKQ1PVHoSxohmH7IC8xEBTrr+Y5fJcrBSK6muFNCKs1Kv8aiLW",
"blD91t+wzzdpcNyGo1RPu52jy+8qM89QJJu5M379w7COvUe9PJp5O8Ddjgufy23GhF5VZi4zmu2VcZ4k",
"emI3e2H0AoRcWOOckFUV1FuqSZON24KdtRJNlqXCsWX1pgNA/7vRgGzBBggKcQV7KMV8H35IIIoYOEBn",
"WHgHzBzavlf2hfZ230edWmlDgx3iDhmDH7IRAPj9uZiulOefyQXBJ2yQwPtFVZfMt05spF+s0dgmnVEq",
"Cwtv7F7IubmGLpvOjalAagQTtV6evdQ/YEiYkO2v/kHCYr73QBvZELTS/o/fZnfW7TeV0lfJfAkkkW8O",
"nRHpW2QI5QqxNFVldrShIowoPL7mvNy7KMCi5Joyss4PX65AXgc8x1cRc1ujtHdCm90aLBQBmXCtFlAI",
"ZxZXQAjeqm3HbSsV6sGgD2jCkcZGJJPL/Ra+p0HSX37gAdOfXvPgh2RJ+xhmbBEdtrFoaG6AUn9SDk2v",
"Pq0i1iPZeti4Y256Q/XNhk6ktXI/rJ7QWm2Iteiols7eWGN8arfiQ/wtqzu8rfUChdqR+ch/3yBnhYUi",
"bWixAwvCAkoPdFr7fHOAfFpZ0SArnT+H8CYy0sV18I2OS/T4Ynbs66y5JHG5WT4nx2B2xM2+i+qKxtJY",
"t+UCOUtHc5fAOnEcZnEk+KSoHaBPtDSL2k3rLU5fsr/J/o5fw17MoTJ6JbyZite62jO345foYzj2gmAz",
"YEDtKyNTqFvEbq3BPwetEXjXQc2wEBy2S3rhg3eTgMYibGAL4lG1jaLj+Rqtqww1BJ06oEjnSku7z0vp",
"Eiq44VNTlbM8n1tAERW2C18Rc1gi5xEj08Mbjdqsc1m71ByXZUnu2MaUaqnoTwKX/UGeP0wxWxBqcm5r",
"nrfDbEWCuxQZDdIGt6INgnb3okV2X06xQe70iRe/XP5w+r+Fh/f+Kf0XiZmEYV6tD2MtqPUxhArvfXaM",
"G6QrgbY2VemEYeaDEJwJUSBc0niJGrRXwFE6cwBvCN3fo0vWw/UWTaTskgbwlYeKh8lOjZIpM29HdiSB",
"JU3R38E4ZfiOWUSt1l5spQbh1mbnnqZuWnyR4q1BwLC48/ng5vyYIr+U7upSzsPbLk+f+ITFL4rsbe05",
"HFegDWZsCXYqLhtrFcHiL9iAa8TwKMMihefArhiMoVwGBA3gr7cPx0QBIYKxl9v8l3pu3r/0sMkI3DFK",
"dl6ZxVWwwvocfRcVPDDRYRiDYpGIMlzu1likmQgrkK1STKz0MKvUJn8g0dWdvWgfjX4uyAl978WjV9Je",
"lWanxU75NUf21kYtAI13Ol+QTvztVDwSj0nW/U2c0T//V/znVi2uTo0mg/1vp+Kv4tf0vb++/zV9T+q9",
"qJTzgujscSEiQIXYrfczwjqUhTAEsBOPQC7WAaATRyO1BP24EJu68mpbgXjkbU1R7Gaex2i/LsxmA7qE",
"UjzClW2kvYIyLvCExntcCNnEttxUMNbPWbA/YjGpZdVEy80yhknJmOHRNZJAVe0fT0Vns84F/W8q0i07",
"bxze9leeqYk044NTfsDK7nERRvp/elLkDCJlrPL7lJICMifFZBWifnCtYIeu0V7lyeZms8qCM9X1Lcm+",
"+WggatI5phwlfYi134bPcjKoiXQfHYWCwMfiU35tQZbjY7MJFtNAbU/spCM3pmWzkYkISg3NGMFNJFCK",
"w0FRyPgq+9LwE/H/nOCgqKnSL/nzbw635ABLo8F1OXO55dXRECex6wy53EWYl1Cpa7D3owJwDUPEFwRh",
"Z6l9Cj1UwoPGYgjrjN7eS/zgYzFBl20k+cfVhI+aMEi6cUcdphyNfQZSGLkTXZ3KD86j6nSgo/p6KuC9",
"XPhqL1A/8vlRoy5bzReUJ4U+kpdQV8aXOqbZ6P0/gJNDb2Spom5j95sjOyScpgFu9vx7MJyHx7JKvpfk",
"3yemA6DOiQjIx4PuRoSHtNaQGY13lIguw6xNbsc2WFkcICwmpXIb5VxWMSbKojME4ABSux2KgnYM+tvV",
"W7AOygET7RWbE0O0TYdAGWL7rjJzoUonltZsRL1FtYC/Ff3tRMONh+FdcqqEZvdvR1ADJ+ev8OcQAFSO",
"jSKpS1FJDxbJ2k3FX0LWSamWS7ABbt/J+6DRi/RH4XbKL9bg0rhENMBC5ChPW1nKfyX3Yg4hXslpMAEv",
"FsRKXYMe4W8Mebg/G6+WIY/qjYWly8XN1WJNqQ0KCYmNX1xEyYijgJgU29qthU6Gm7I1kQYogk0wi0yZ",
"me0/4yMhHfIiMWf88FygoTwVr7WY7zMJDYmTQ/ZmZvyOgctryQ9wA4w/G30aQWoEzMAwrZGcGSi1q2+C",
"ha36nOuMROvqzUZaBW5ENCSzBelS26kaDHaXkKOhN1LDQLiC4qvZ2Dk6PLu1qpg75tbskIx2SpdmR7F0",
"+rQ5pgoDCeBkEWQjpRuZ0HrkI1AQYcovxXq1UFuZi6+Vym0ruZ8NHnStlJ/BRqpq8Gn8NJehsYvJcsRQ",
"a0nBOgdeSDrnViVor/z+KcemPb69Ai+0Ed5KVYHNyhQKZx83fvi1orvGBOZ0cVnEsdvwWVLIAt/P5lbq",
"gSDcgBU0uHMWNsYPPdqa2bigHin0gK72sx7AzXS3Oz0PKB7SuTesbfQCerAPwOHNwlQDZ0+3cq/ljmik",
"9jEPlJPwvhV/Vt/dEM2MkR0LC1DXTSj+YNnSDWT1HMsEzZ5Xx3lPnFjWVSUq0Cu/7pxb9wPfY/M7m/yn",
"jocd3GNaByMrAHd0W1wuqaQOrsZd8yAuOF0ZkcCxWlnKLdpKC1NXJUqrrbSOvIFS0b8tLMxKKwfCKb0A",
"Md9biBm8YxImcPWLcNbR342NcTQF6gM01wo0UsCF0PHoeHCXnj8ecf8DGhvIsltRu/WfIZOvsa3nlVrM",
"riBzSvOddPDHb2tbTfqn/bVGuC2gcS7enP7uD3/k7ILAIr3DhXaaIfAu6nkyeY+Ha5YW8wjTTRnQyUgn",
"TuCnwsHC5s8LQJcEef7o5Hd/+GN514n5YySqo9ZwA0QzZcFLzmErZFx+CVdna81me1dnJxcf3Wz9iKMp",
"fi+Li5h+eLdjiYXZbJSfubXMpWHzUzrdswD4Vsg63kobTL40jePEiT+9ePZ9Uxiwk054eQX5vNa7mCGc",
"QR+O4/PnQSeO3EXnQxFHY8PFlEMCK0i8EL2wAOFck3OHnG/WJeptmwg54lx1yMzpA/zGOJL5B0mZEUxX",
"MJ1+8zQcoi+pXAWW7gxl9hm+7s4+hHjwxzP87uyD/ji9ZV5CMYl7O/BQrVa5jI7gPZ8nTMNVJBTLoSKZ",
"TiDKG1OJhawq95Qc+xno8jy8hws6cQJ0eWqWp+T2M+lN6bMZflag9zlb1xvJMQEkESvkdgvS8nE0uvkR",
"eUx3qMlA2krhHLWqyk5Secy0KSYRHvwzzocCKE6Iv+N0I9O+Ik9eMu5eNfMcPEBX8QXNevjAmOo5w3Dw",
"5Jm7+lOA6PAbBvBIdnfm0EBPOmIgoYeU31pKOGoVXzS5G700JXUNMyLcvHHD0IXKpJwIf3ENdh+oLRYw",
"kQWDkshK8qMLocqqifygU0qJlOVo66NTXpaL74ec2ZvGoLxaNNfmM7JFR2ZlJGVC43OOKsQq4SSDsWcp",
"rsAJLrHw1T4mzg6k6bJnM7BP22CfzdqQzmhzzoVITg3ljDJ48yGUbsWINaix21IRuXVFlJt0dMse+E5V",
"FYv28akHtdVvYZnb54M40REbvaX/jJPCCql3CCvoNDoUM3GYZ7QVzrt3MwpjbrQ2u0KYqkQgYkT0dhb5",
"DWhyDmaLSkXyyyQGs669laLPlFr2F/knsxN0XCCblZLu2Mi9WBmKU5raC9mt6gwRYOUFOw6CzIF6cZXn",
"hH+YzVzlZNFFFDAhCSCCQOFRbURFtRjMeAXnOB7wIkmutXRUeBq3hfd71Kb8XwLtqH+U4D8RSD1hkzB9",
"0ZXV3T3uCpxDSjzg7SAtu8zUF/QtmvNb3xc5OcVzKd1VJgdPOpiFipV+Ws5wwGpRGTdUFhltSkpAQnty",
"A3YFJfvZbiFt0De3C659yZpOHXP4GjM5GPD8ppA+zx43FnImOVrKkTWYjIifZAoxieRuVYWJqJ+xBZMz",
"U3FjeI3uXS3duvFqeAtDJFAwJTxND3UQHgZZG78m8WJ4p7PwMSBe+WogVkWPGjx3gHmF34qNvAJ33lIW",
"f1Brr6rEzHZAOdOMLpI2DvwsmT2famusnyEvDGWjWtfkcQZXhE4m8Dc6fxCPmiEQD80//u3fHxdiKykQ",
"EkvHwddbwVMkkk868eb128sbLY6basEPEoJJRE0C3sNpKbPeSDsd5QUbq8/iWO1Pr+Ko7U/ft+OjzI0b",
"3feZTGlmKuQKHiGDy9ffv55u+KipK1KC2L49n0R3/DYh8m6OEa2sS9CNqDwcv2gFbEpkSVL1bSLqiGxO",
"bHd5SZ5HaWRpy7486kppw1HleTjstSs4JeJNiDxwfXOm1RUGBcdIqU6Igz9wrUzd+ugnjubLV7ir6hY5",
"UElCfy7/wkJu4VTVtOxW0kgn1BItHbtnkUXlLs7LFaVB2o6MH4b/8IySt5TgiEsb3L6BsNwQvxyei9Nr",
"Q4NfcnJzlAHeSs0oQdIKdBMBLCZ0Ot5S92ixML9Mx72U8+fN0Jdy/kMY/VLOX4YJLuU8HrehaLg0pcmU",
"W71XzmdP84E5ukeA3IGkkRHSk3Q9KIxKDqUbUhtn05vSUEbzMQMyAB7Hz+5NHKvPtKbchx4e/cxgHPAE",
"XfQStKcUBWFhY66TbiJ9Zg8fLWtLaKPDj6wPAYsYS88IYXjfqDsN0qJrtAZZUuUhFVVG+DImgXJDSeWJ",
"WhgGnFQtgl00PgruK+UxkzMRsqoidsIx84ickiiqEectAoY27AI1TKZUbTBePda2YWVmWlf8ziwf3M4e",
"jEfC2g/f4CUH7S86mCKZk75NPt3kOT+IxmiqkygbXFrQJwO2at6OOlLD1ailLhwk03hKTqxq86poTSJq",
"gO6A4Rxso7ynNHiSkknEjc/CUBA7RMYgVMkomUC59GvHMiM/W7ckOIOV3oHhJvihraJo4OsWfqWAZbc8",
"5LE+WFrK6OyRYpJmes22MXPsJg3QTzX7lCSULARDSPuFCjwzrQfuipnDSv6xaTIhiNLfQ/BDUByW2MQI",
"SBDmMa7Bhgf+1TwJMiVfLHHHLlmjS05DRUBYWB8XH4lRlyYx1ibzvQUSepbbUU2eTJ9Mn3D6Mmi5VZPz",
"ye+nT6a/58q5NaHuTG7VmSw3Sp+1btsKSNUgiok+XpaT88mP4C+im9ApHPjdkyf31l0uzJBpL3cB9hqs",
"4GZc5ATUTqJ3/5FepnXMK8MVclvjMvGFC08dHdo2K8aK2lHzBBkzTLlQDpVyPDG1csfvPyXNjxuBj0rw",
"sIg+H2fh7T1wyt2mdl7MQbz5+cdC/MebFz8W4seXP6BF/xeYv5mKZ56zL/4gXqnvuKqni+tfmgPvCdMF",
"OP9dMM4SPBNYZyu17CK4iUq15Zo9YuNP/76F1V2/3eo7f7qD+fa233YYxNsaPvbI8Jt7I0PCfIYIKXEC",
"qagUSGtTZK5vmfxzwzXwnTXZMd9+8/vRbx8Q9tkH/N/L8uNNLNrQzI0M+hsjnP4mIiZPHLMsW/bE3AFF",
"xORhc7+9xXahXLVyAx4seqcfJqj9Y7Ey68IJ7+HkkJqLZE2HK/g1kgH3YEi2PyciTyn4xK9OxQu5WPM/",
"uAegciHq9+4pt9gKUo7aVQjpxH9cvP55yv904cj/J+n8Kf1y+vJ78SgEJvjZuxrsXjTLfkxp9RbQAEEm",
"4fMbOl6JEFFMOyjCIhanSx8DmbHPhAVK9XnKm1OBLP8P4aktZw9vBj9mIauKmlK0XSuaZdAQUgtqeCFW",
"Joxh2pYUOSHMCXs8xCS/teiFkmkdNreDqUmRo9Ohs+GPRRiTMNoOSWi+n6Fax2iY1gY+pc6Cme/aLPFf",
"jwoddOOZhE9Dv9IOJx/lWyZR/vQOzBmZiANNQzz0si3SKMRardbgvIiFmSEc8KgpVV5JD4Xg2tpCLPfq",
"ccGp5eEAlw9uxQ+qQroRWlrL9fYiVIFZIkMZ+wacOJF2gsRhc5T5k3I+hrNyZHmweZ2A8a23/hOoJhas",
"jlO6aVXuCHK6WbuPr2YdCKv1+5hJv6AoLVfwxLiYsqIBdCq+Z6/f4a6i1R3LUg7p76yzuLy5+oxqy1wn",
"M4vnVpp+dPV8o7gZpYgvW0pT6CRH4qtNAbte0HE6qoGygtBKWVM0CPVCvy6HJHGtY6FbjhxDfXGkyGF7",
"9e4GWaeOeZQx+KXJBa2M0BmpIYJbG4u3EHD49r/fVRyefUAQj5iW7fIf0AFMcJzHKcUrG4OtZb2HsdYY",
"LXez1jj0MYTNV/CQaKRY1wAG0VJCFie7JyCizgDJYZ8A5/2zdBJZegCGvityIssG5NyOY1uu2sDZMtb0",
"DVEAF/094Dp5gsxCm16OKSmcuF5rni51HHbOXhhbsvPAmb5ct0upF52iwrYtUsFBc3Qg5HzaN7hTnNw/",
"wSXo+Hy0NrgHSSiB2+g9rHrokCZa82fBJLgpXga6dG1oLFZdHBBN628VfIjEDUHTXtm58zjxl6Z8lV5V",
"TmxUeRqa63QqvtdQlWSpuFju4Z3Q8J7byuYskpDG/RN7LQ9BTN0S/lFE9bt7m7xz7cAAbXXwnyJ0Fwpo",
"jG1wS4mMnyLriKBiFvegb3V5E/E0SeDFgfP0jFNE6HhUxBbybNUSTSDJuXA2zL0N+s4SEkLMAZ18DlOx",
"u0PjrMX2WoMuctOTmlAHl2VW78SOmg2kjQYOGbbpvdlrOOBiPmvOCuifPj0MW+VOuf4b2gaoFk8bA+FG",
"/bpLMk5YzyruBk+uqD4s4G/F6h49t6IVsMxlC6lPvNi0nah7KrjtR/AwW9yOP2prvx1CDZSfuAe1W5+m",
"VZDhKBHFS99ee0upLr3az1Hxl6Rg8hZuxDhMUAIOJYuF3FdhYaWcJ189mG9Z5f42vIYKPvSPQEMNpUKK",
"lKbFc8drEPFrvo5JxBUKuZJKc/yXughmSOxZWWbQ+CCUdjjN3QkuQeldSS4t08m6BKio3rRp/Q+vpGIa",
"3Aj99KyqohHvDsiquwg2iOLID7SvnVYOn/losEFaXkdo2EVE3QOtnH0Ifx0JzaT4fiDFeGTd3TXfdxim",
"wcLdIjE5dJ41tY2DzHgZKmoenhOpGmakmdgeFdAKDts2fE34H9I9zLmuU7/QJnc3+dFNZkW75H5WXU78",
"XPJ1Pw8he5Ks6c8seJhGhqUOIvKzBRHyHBVSqQedP3ZJzDLNko7XR8Rz3KG9phpS6sPODa17W/8jeMrl",
"fkAZSOPfEJfmKyadpwsll7Jy0LY0yGSLaxMR8c8kNk1pWtn5xUQHn5GGlplpYQ53frZljEPSeUGIGTXF",
"PkUaOsAnoSlCmuuOH/mm2qpJD3sLS2qlxt3XYmkXFzgxTOvwoZMbHimfXCCtbzLxH0hWNXnjvz1Rddfj",
"MHSAzkKHoUFrKzQoekhrK0wxGCqy1+Sv/dezNy+/F9yxSFzBnv39ZF7OCfoz8F09OOwrqeUK7DR4efNu",
"CmUMQp19CH8dsTxjjOsBcTEm2tlcyvggcrRBxd3kaA6nZyGoOU6I3hGAISH6zF2xiEq7b5xEKcgydS11",
"aZZL8WhlZFWIEhYqhGcpqwN/kSU6/q7gTIcmY6Dge9Aeh/6vnNxVWx1eTlu9KtdEdMtwtRDKh/g4RK5J",
"kJJopeRaajwbZHcjsSO4QdJ3hXZG/j5n9A+S7+cN1kfoCU1pQamhDLsabm8h3FUAZolVaQ/W1tsvQ64v",
"dDhnDA1R+Nint6UvI5BfyaY2SKMdbbrGf9md7Jz6fe6NvGCmZSZnWmf+Va6bfx8FQEykRzmVC1brsm3Y",
"9Js85SMkrluB8ZVaVUkDtCP2ROO6P5QLd4Npmpil921B8MrvZj50kXe2aEvus472i7aePH+FsHjU1MkV",
"TbV5rfkvrqzR1KALf8f1woLO9NvrTx8LqnXm+4g7Bfy3qN8fdvRNbJVBXU/Jg+ZG7SQbkg6GZqepHvk9",
"ZxV44U29WEMZksxj6XzRdg5UoXAyqapX2nmQ5fGFcIEANS8Y6kEQIb+pAwEvyHnYcie0OmufBFZoi9kP",
"aGyg/aM3cRkhR44XF/ehXVAhXI2b4cJtjtOYHhBrBdYgy6n42Xghq8rsIlriYqjxUS7lF/fsWK7wIfAJ",
"hvQ5pR61VEMGHWlQpbt7J6wMJ6OS6ZzKDNLa0iyA7R3gPRg7GfSZTjzx2/MukrO7zZc6Nf/i/TlxsR1n",
"PFYLvVw0PB4E2MP2U1PvPzHeGYgwIzbfgD2loBw6BV9biuvnFszmVNZ+HZrsDQZB6RDfxc4wkXa5fCVU",
"qrTJTx0B3nbQEI8oh6ONXDWfkAxay5KqHCkPhDO3W/uz7c2nrEAsPG5vVQtyQtnDqNezWFq+T6FFqRwa",
"41HDKAY+bbcvpHge0QLlKX4euu3nZd5z8yyg8LMcgCb3FYw8fWk3+eu3FMpw9+YgKdaarmelNgK49XRT",
"EXLzHPwOQAtJvQRY3TUnMquOapoOqS66+XNUfkToVzB+wUOVKtzy5pPHCRvyyeM0d/DeLs/jHpNP4wWs",
"Q0eJ0i/Wv22hXSo7yCKvdXpDebffWjQziuYNBS7eukolYPM9SdepeBFuNKdrj8mKppqaCpZemNo/FY1d",
"XTa3n+MLsTUHR72ocSUKYrqyeejmfJzbmnq1FtMpl5KFG+iL0JG6dvkanoZp7TFb86KSbn3qAF/y7CKM",
"uCkeYWnboqV3xt/AgV+Ga5Jr+Af4piEKaoTt+fKg3zYXUbe9cUGlu8w7nMW2MdfdTIK2vZouo88ZWzeh",
"EaP0ih3J8D4fF9LboaLYwkaqTIAx9BT8kiGKpm3i3YIV9xO/OVaHniAX0jpyKRy8O0zvfiPZrNxK5+KZ",
"bSWp+fE7MZcL8gDp+3xqN2Lrxhrth6unpha5naE28r3a1JvJ+TdPnjyha1HDPzODfpYKV8LMGNv2F7o6",
"gRteh017FKMbf3jy5PHXb+2i4TpIks+EhVVdSRuu8s8GxqbiMlzob8HXVqOvtOX6bKf+AYydp4I7TfAh",
"V3hTaBN7Rzy4ikaj7n519BEt/Ck5y/dryz4PAw0IR9yR37Yepi4pw0zQND5CHmiy63oWLVpoTf/HeLWO",
"LsPHnQvTidKVQ9vOVLWnQvUhWnsa1ADfCo6yPhA+Rfiao572XsKWL7uNlApqo0QgXfzXj0W8eHe+F/De",
"g6ZEgGH+ebnh86IxjRPunwH+WTvvuOvVv73fVF+8cQ+Sbmzccyde//1vUzJQ+O7z2+cXbXUeZXFwtJIv",
"Cw6d+E+VzmaKCI0UUlX7Ilx8R03YB2x9pb2h8FU8B1G+Sk9ywvtJl+yYXDJ8DBWjlgdBzxDlzIc1SXZy",
"QLQTLmjDhgXV2FBtDFdhqpXShVhK50+Xxu5kLKpuAaOLtU285TGEF2zi+eRcnhg0aPEVLj7opTAyylV7",
"BUmbdJItrbUryLtAn/fomwPSRzNmvu6j8LPIAp+fNZsksECigTVNROwNvMHsdnCwWySKXeo92sPLSi08",
"2w4WVqCJjvRKhD+hZCN6Ki6SS/0COGlqGh1quJg80ikqVz6kUmu4D8J+qf9F1vdB1pla8L7r/tVXZHc4",
"Y6A+/evxi49lXenuQoayQ0KjhZI1VHOPZjezMp+Bx3lSafrd/adkdS8U/cyJ7WMYOElg/Sfh1XgR45iA",
"XnLlZTeOR40m0RNMbyIdf9vFFSCJ+fylmS+TOzOTK3ZI+ajshU7hIk6zi9Hc4HuiiTnUUe+iwcPnkEfN",
"ZbBju0NE6L7+SFwDKu7X2dLYqwezborsQHrMGJ1I7LFio1i1Eu7UzVUrkqZuoypJThMzRNG0IeXOex1Z",
"TK4PEpjSdTDqkwFC6wajr8E6vnqD06KcN6apoutmHvHdByFJLrmx7uoxn34kYuokleqxIWwnAaS5dVn5",
"QysrZUS+ho2Lq7iKoL3IN9y0Z0PvhrCmOL6kC4Ga6/5yLPqDsVcXbfLWv4qXjgr2OrnjIivXmz5ghzYA",
"X7BMRIk6f9APKA04feKzOSDNDRsP2a0lzjHUsSU8jxdQTP9pozofP/7/AAAA//8=",
"7H1/cxs3suBXQfGuSva9EeXsZrfuyXV/OLaT9ds48YuUl6rbS2lBTpPEagjQAEY04/J3v+puYAbDwZAj",
"WbK9L/tPInNmgEajf6O78X4yN+uN0aC9m5y/n1hwG6Md0D9eWmss/jE32oP2+KfcbCo1l14ZffYPZzT+",
"5uYrWEv8639aWEzOJ//jrB31jJ+6Mx7tw4cPxaQEN7dqg4NMznma6QQfhHdxqGdL0P6NNXNw9O+NNRuw",
"XjFoK5CVX+Ff3bGcr+fXYg1SOyGFr60Wyglba630UkhdCm2E8xbkWsANaC9W0glprbqBUiyMFTTClVx4",
"sFcO5kaX7qmAd8pDGcb1KxAbBgwHXxoN00kxAV2vJ+d/m5jrScFw4I/04eTXYuJ3GyAArdLLSTF5d4of",
"nN5Iq+UaF/W3yV9oUT/id/znRRiF//UyjPWhmFTS+SuC/0r6PhZ+WYHugInvi601HoQUldIgvBHKu4CK",
"STFZGLvGoSal9HDq1RomxcRYscWhlBfOS+uhnE7OdV1V+8v5UEw2qkRAwu9Ke1iCxQcOnFNGX3Wet985",
"Lz0QZTlhtKBtv+BPmsm6q7tcgQiDnuAK8PsMQF6666FJb0CXTNp7jz4UEwtva2WhxB1JYG8HbD7nRccl",
"7O9KEYm03Xwz+wfMPQKQLrNP3XML0kMZ9ja7Nb0lxW9mu+yKF8ZeNyPuUYvyKxGeL6xZnxPp8BpPnFCl",
"MAv6iahoDc7JJdAP+JG4Bti4aQ6kZMz+rM/o6xMnFsq6QF/CgqvXgCymXIAgbnUh5rUX0otmJYWodQlW",
"SKFhG96+anfsqZAzhxyOXG38Cpqh8tAOkMralFD1wf8J3tbgSCjgC08FrDd+l0iIBn8lLGRd+eycZqvB",
"9gdHCq/MUmmxXRkHogJZCokkw5hRLs6Ha5MCKRNZIbJNZiZrKuKyKKXUelPBGrQHpGOcoC+kUu7sAvi2",
"hrqRh62c3UrlUc4yVAsLIFxlfNgnxMrc6HltLej5Tswl000DUlkhZfPYk2ISpPakIB1VwXhBmnLXBa7g",
"FQ/d+/0/41y9Jz81k/cePW+h6YqZPbWWbBBt4KHtaQVSxMa8knUJ2U3pkfpxQbYnwJjuEkHGVJ4buhVv",
"REIdQVOkkior5ryX89U62A57Qk4GTGWYDnR5hWoqr1HUWi4zREk/n4tSeil+/un7LJ6vlS5TLC8UralU",
"i8UkDoz/lEtLahFRhijQcuNWBoV6bavspqzBrmWODsJg5+I1vyGcqe0csuBtZM6oQSDPhYWNObVQSa9u",
"QOCbUxGWLDX/JfBNoVj3B6GwNfbaW4ACxaGpag/CWNGMQ3ZAXmKgKddfzGJxLpYKRfWNQhoRVuplfjUR",
"awdUv/UH9vmQBsdtOEr1tNs5uvymMrMMRbKZe8Wvvx/Wsfeol0czbwe423Hhc7nJmNDLysxkRrO9Ns6T",
"RE/sZi+MnoOQc2ucE7KqgnpLNWmycRuwV61Ek2WpcGxZvekA0P9uNCAbsAGCQlzDDkox24UfEogiBvbQ",
"GRbeATOHthfKvtTe7vqoU0ttaLB93CFj8EM2AgC/PxfTpfL8M7kg+IQNEng3r+qS+daJtfTzFRrbpDNK",
"ZWHujd0JOTM30GXTmTEVSI1gotbLs5f6DYaECdn+6jcSFrOdB9rIhqCV9n/+OruzbreulL5O5ksgiXyz",
"74xI3yJDKFeIhakqs6UNFWFE4fE15+XORQEWJdeUkXW+/3IF8ibgOb6KmNsYpb0T2mxXYKEIyIQbNYdC",
"ODO/BkLwRm06blupUA8GfUATjjQ2Iplc7jbwggZJf/mWB0x/+pEH3ydL2scwY4vosI1FQ3MDlPq9cmh6",
"9WkVsR7J1sPaHXPTG6pvNnQirZW7YfWE1mpDrEVHtXT2xhrjU7sVH+JvWd3hba3nKNSOzEf++xo5KywU",
"aUOLLVgQFlB6oNPa55s95NPKigZZ6fw5hDeRkS6ug290XKLHF7Nj32TNJYnLzfI5OQZXR9zsu6iuaCyN",
"dVsukLN0NHcJrBPHYRZHgk+K2gH6RAszr9203uD0Jfub7O/4FezEDCqjl8KbqfhRVzvmdvwSfQzHXhCs",
"BwyoXWVkCnWL2I01+OegNQJvO6gZFoLDdkkvfPB2EtBYhA1sQTyqtlF0PF+hdZWhhqBTBxTpTGlpd3kp",
"XUIFBz41VXmV53MLKKLCduErYgYL5DxiZHp40KjNOpe1S81xWZbkjq1NqRaK/iRw2R/k+cMUV3NCTc5t",
"zfN2mK1IcJcio0Ha4Fa0QdDuXrTI7sspNsidPvHi58tvT/+38PDOP6X/IjGTMMyr9WGsBbU+hlDhnc+O",
"cUC6EmgrU5VOGGY+CMGZEAXCJY2XqEF7BRylMwfwhtD9Al2yHq43aCJllzSArzxUPEx2apRMmXk7siMJ",
"LGmK/g7GKcN3zCJqufJiIzUItzJb9zR10+KLFG8NAobFnc8HN2fHFPmldNeXchbednn6xCcsflFkb2rP",
"4bgCbTBjS7BTcdlYqwgWf8EGXCOGRxkWKTx7dsVgDOUyIGgAf719OCYKCBGMvdzmv9Iz8+6Vh3VG4I5R",
"srPKzK+DFdbn6Luo4IGJ9sMYFItElOFyN8YizURYgWyVYmKlh6tKrfMHEl3d2Yv20ejngpzQd148ei3t",
"dWm2WmyVX3Fkb2XUHNB4p/MF6cTfT8Uj8Zhk3d/FGf3zf8V/btT8+tRoMtj/fir+Jn5N3/vbu1/T96Te",
"iUo5L4jOHhciAlSI7Wp3RViHshCGAHbiEcj5KgB04miklqAfF2JdV15tKhCPvK0pit3M8xjt17lZr0GX",
"UIpHuLK1tNdQxgWe0HiPCyGb2JabCsb6OQv2RywmtayaaLlZxDApGTM8ukYSqKrd46nobNa5oP9NRbpl",
"543D2/7KMzWRZnxwyg9Y2T0uwkj/T0+KnEGkjFV+l1JSQOakmCxD1A9uFGzRNdqpPNkcNqssOFPd3JLs",
"m48GoiadY8pR0odY+6fwWU4GNZHuo6NQEPhYfMqvLMhyfGw2wWIaqO2JnXTkxrRsNjIRQamhGSO4iQRK",
"cTgoChlfZV8afiT+nxMcFDVV+hV//tX+luxhaTS4Lmcut7w6GuIkdp0hl7sI8xIqdQP2flQArmGI+IIg",
"7Cy1T6H7SnjQWAxhndHbe4kffCgm6LKNJP+4mvBREwZJN+6ow5SjsU9ACiN3oqtT+cF5VJ0OdFRfTwW8",
"k3Nf7QTqRz4/atRlq/mC8qTQR/IS6sr4Usc0G73/e3By6I0sVdRt7H5zZIeE0zTAzZ5/D4bz8FhWyfeS",
"/PvEdADUOREB+XjQ3Yhwn9YaMqPxjhLRZZi1ye3YBCuLA4TFpFRurZzLKsZEWXSGABxAardFUdCOQX+7",
"egPWQTlgor1mc2KItukQKENs31RmJlTpxMKatag3qBbwt6K/nWi48TC8S06V0Oz+7Qhq4OT8Nf4cAoDK",
"sVEkdSkq6cEiWbup+CVknZRqsQAb4PadvA8avUh/FG6r/HwFLo1LRAMsRI7ytJWl/NdyJ2YQ4pWcBhPw",
"YkEs1Q3oEf7GkIf7g/FqEfKo3lhYuFzcXM1XlNqgkJDY+MVFlIw4CohJsandSuhkuClbE2mAItgEV5Ep",
"M7P9Z3wkpENeJOaMH54LNJSn4kctZrtMQkPi5JC9mRm/Y+DyWvIDHIDxB6NPI0iNgBkYpjWSMwOldvUh",
"WNiqz7nOSLSuXq+lVeBGREMyW5AutZ2qwWB3CTkaeiM1DIQrKL6ajZ2jw7NdqYq5Y2bNFsloq3RpthRL",
"p0+bY6owkABOFkE2UrqRCa1HPgIFEab8UqxXc7WRufhaqdymkrurwYOupfJXsJaqGnwaP81laGxjshwx",
"1EpSsM6BF5LOuVUJ2iu/e8qxaY9vL8ELbYS3UlVgszKFwtnHjR9+reiuMYE5XVwWcew2fJIUssD3VzMr",
"9UAQbsAKGtw5C2vjhx5tzNW4oB4p9ICu9rMewM10tzs9Dyge0rkH1jZ6AT3YB+DwZm6qgbOnW7nXcks0",
"UvuYB8pJeF+Lv6pvDkQzY2THwhzUTROK31u2dANZPccyQbPn1XHeEycWdVWJCvTSrzrn1v3A99j8zib/",
"qeNhB/eY1sHICsAd3RaXSyqpg6tx1zyIC05XRiRwrFaWcoO20tzUVYnSaiOtI2+gVPRvC3Oz1MqBcErP",
"Qcx2FmIG75iECVz9PJx19HdjbRxNgfoAzbUCjRRwIXQ8Oh7cpecPR9z/gMYGsuxW1G71V8jka2zqWaXm",
"V9eQOaX5Rjr489e1rSb90/5aI9wW0DgXb07/8Kc/c3ZBYJHe4UI7zRB4F/UsmbzHwzVLi1mE6VAGdDLS",
"iRP4qXAwt/nzAtAlQZ4/OvnDn/5c3nVi/hiJ6qg13ADRTFnwknPYChmXn8PV2Viz3tzV2cnFR9cbP+Jo",
"it/L4iKmH97tWGJu1mvlr9xK5tKw+Smd7lkAfCtkHW+kDSZfmsZx4sRfXj570RQGbKUTXl5DPq/1LmYI",
"Z9CH4/j8edCJI3fR+VDE0dhwMeWQwAoSL0QvLEA41+TcIeebdYl60yZCjjhXHTJz+gC/MY5k/l5SZgTT",
"FUynXz0Nh+gLKleBhTtDmX2Gr7uz9yEe/OEMvzt7rz9Mb5mXUEzi3g48VMtlLqMjeM/nCdNwFQnFcqhI",
"phOI8sZUYi6ryj0lx/4KdHke3sMFnTgBujw1i1Ny+5n0pvTZFX5WoPd5tarXkmMCSCJWyM0GpOXjaHTz",
"I/KY7lCTgbSVwjlqVZWdpPKYaVNMIjz4Z5wPBVCcEH/H6UamfUWevGTcvW7m2XuAruJLmnX/gTHVc4Zh",
"78kzd/2XANH+NwzgkezuzKGBnnTEQEIPKb+1lHDUKr5ocjd6aUrqBq6IcPPGDUMXKpNyIvzlDdhdoLZY",
"wEQWDEoiK8mPLoQqqybyg04pJVKWo62PTnlZLr4fcmYPjUF5tWiuza7IFh2ZlZGUCY3POaoQq4STDMae",
"pbgCJ7jEwle7mDg7kKbLns3APm2CfXbVhnRGm3MuRHJqKK8ogzcfQulWjFiDGrstFZEbV0S5SUe37IFv",
"VVWxaB+felBb/RMscvu8Fyc6YqO39J9xUlgh9Q5hBZ1Gh2ImDvOMtsJ59w6jMOZGa7MthKlKBCJGRG9n",
"kR9Ak3NwNa9UJL9MYjDr2lsp+kypZX+RfzFbQccFslkp6Y613ImloTilqb2Q3arOEAFWXrDjIMgcqOfX",
"eU74zaxnKieLLqKACUkAEQQKj2ojKqrFYMYrOMdxjxdJcq2ko8LTuC2836M25f8SaEf9owT/iUDqCZuE",
"6YuurO7ucVfg7FPiHm8Hadllpr6gb9Gc3/q+yMkpnkvprjM5eNLBVahY6aflDAes5pVxQ2WR0aakBCS0",
"J9dgl1Cyn+3m0gZ9c7vg2ues6dQxh68xk4MBz28K6fPscbCQM8nRUo6swWRE/CRTiEkkd6sqTET9FVsw",
"OTMVN4bX6N7W0q0ar4a3MEQCBVPC0/RQB+FhkLXxKxIvhnc6Cx8D4pWvBmJV9KjBcweY1/itWMtrcOct",
"ZfEHtfaqSsxsB5QzzegiaePAXyWz51NtjfVXyAtD2ajWNXmcwRWhkwn8jc4fxKNmCMRD849/+/fHhdhI",
"CoTE0nHw9UbwFInkk068+fGny4MWx6Fa8L2EYBJRk4D3cFrKrDfSTkd5wcbqszhW+9PrOGr704t2fJS5",
"caP7PpMpzZUKuYJHyODyxxc/Ttd81NQVKUFs355Pojt+mxB5N8eIVtYl6EZU7o9ftAI2JbIkqfo2EXVE",
"Nie2u7wkz6M0srRlXx51pbThqPI8HPbaJZwS8SZEHri+OdPqCoOCY6RUJ8TBH7hRpm599BNH8+Ur3FV1",
"ixyoJKE/l39hIbdwqmpadCtppBNqgZaO3bHIonIX5+WS0iBtR8YPw79/RslbSnDEpQ1u30BYbohf9s/F",
"6bWhwS85uTnKAG+lZpQgaQW6iQAWEw92rTQV9NFBeUvooyXE7DKd4lLOnjezXMrZt2EifK+d61LOXoXp",
"LuUsnsOhzIgvXYTzjP3zgIprhZRWa1zgVzlBac326Fu9CHlFNhh+mcWsKU2mPOydcj6bfQAsgXoMwx1T",
"GpkmPWmDvUKu5BC9YY1xPogpDWVgHzN4A+Bx/KEV57O5Z6bchZ4j/UxmHPDECaVL0J5SKoSFtblJup/0",
"hVP4aFFbQhsd1mR9HpjH2H9GacC7Rj1rkBZduRXIkiolqQg0wpcxYZQbSoJP1Ngw4GQaINhF41PhvlLe",
"NTk/IQssYicci4/IgYmqBXHeImBowy5QI2ZK6wbj62NtMVa+pg0d3FlEBTe5B+ORMPzDN6TJQfuzDqZT",
"RhKt8+kxz/lBNJ5THUrZ69KCPhmwrfN235Gas0aNduEgwctTciJYmwdGaxJRY3UHDOd2a+U9pe2TKE8i",
"hHx2h9rCITIGoUpGyQT2pV85lhn52bolzJMR4nsd/Oao2BL4uoVqKWDZLQ95tw+WRjM626WYpJlpV5uY",
"6XZIA/RT4z4maSYLwRDSfqaC1EyrhLtiZr/zwNi0nhD06e8h+CEo9kuCYsQmCPMYh2HrCP9qngSZki/u",
"uGNXr9ElsqGCISysj4sPxKgLkxiXk9nOAgk9y+2zJk+mT6ZPON0atNyoyfnkj9Mn0z9ypd+KUHcmN+pM",
"lmulz1o3cwmkahDFRB+vysn55DvwF9Gt6RQ6/OHJk3vrhhdmyLTDuwB7A1Zw8zByWmonlzDFdz8UvI5Z",
"Zbiib2NcJh5y4akDRdsWxlhRO2r2IGNGLBf2oVKOJ7xWbvn9p6T5cSPwUQke5tFH5azBnQdOEVzXzosZ",
"iDc/fFeI/3jz8rtCfPfqW/RAfoHZm6l45jlb5E/itfqGq5C6uP65OaCfMF2A898E4yzBM4F1tlSLLoKb",
"KFpbXtojNv70HxtY3vXbjb7zp1uYbW77bYdBvK3hQ48Mv7o3MiTMZ4iQEj2QikqBtDZF5vqayT83XAPf",
"WZPN8/VXfxz99h5hn73H/70qPxxi0YZmDjLo74xw+puImDxxzLJs2RNzBxQRk4fN/foW24Vy1co1eLDo",
"Qr+foPaPxdWsCye8h5N9ai6SNe2v4NdIBtwzItn+nIg8pWAZvzoVL+V8xf/gnoXKhSjl26fcEixIOWqv",
"IaQT/3Hx4w9T/qcLKQrfS+dP6ZfTVy/EoxBI4Wdva7A70Sz7MZUBWEADBJmEz5voOChCRDH4oAiLWEwv",
"fQy8xr4YFig16SlvTgWy/D+Ep7b8PrwZ/Ji5rCpqotF22WiWQUNILahBh1iaMIZpW2jkhDAnGPIQk/zW",
"ohdKpnXY3A6mJkWOTofOsj8UYUzCaDskofl+hmodo2FaG/iUOiFmvmuz2n89KnTQjWcSPg39VTucfJRv",
"mUT50zswZ2QijoYN8dCrtqikECu1XIHzIhaShnDAo6a0eik9FIJrgQux2KnHBafChwNnPmgW36oK6UZo",
"aS33BxChas0SGcrY5+DEibRzJQ6bo8zvlfMxypYjy73N6wS4b731H0E1scB2nNJNq4hHkNNh7T6++nYg",
"rNbvuyb9nKLKXHEU42LKigbQqXjBXr/DXUWrO5bR7NPfWWdxeXP1GdXCuU4mGc+tNP3o6tlacfNMEV+2",
"lFbRSebEV5uCez2n439UA2UFofWzpmgQ6oV+HRFJ4lrHwrwcOYZ66EiRw/bq3Q2yTt31KGPwc5MLWhmh",
"k1NDBLc2Fm8h4PDtf7+rODx7jyAeMS3b5T+gA5jgOI9Tilc2BlvLeg9jrTFa7matcehjCJuv4SHRSLGu",
"AQyipYQsTnZPQESdAZLDPgHO+2fpJLL0AAx9V+RElg3IuR3Htly1hrNFrEEcogAuUnzAdfIEmYU2vSdT",
"UjhxvVZCXerY7/Q9N7Zk54Ezk7nOmFJFOkWQbRungoPm6EDI2bRvcKc4uX+CS9Dx6WhtcA+SUAK3/XtY",
"9dAhTbTmz4JJcCheBrp0bWgsVonsEU3rbxV8iMQNTNPe3rnzOPFLU25Lryon1qo8Dc2AOhXqK6hKslRc",
"LE/xTmh4x21wcxZJSDv/nr2WhyCmbsuBUUT1h3ubvHNNwgBtdfCfInQbCn6MbXBLiZcfI+uIoGLW+aBv",
"dXmIeJqk9WLPeXrGKS10PCpiy3u2aokmkORcOBvmXgx9ZwkJIeasTj6FqdjdoXHWYnsNQxe56UlNqNvL",
"Mqt3YkvNEdLGCPsM2/QK7TVIcDH/NmcF9E+fHoatcqdc/w1tA1SLp42BcFC/bpOME9azirvXkyuq9xsO",
"tGJ1h55b0QpY5rK51CderNvO2T0V3PZPeJgtbscftbVfD6EGyo/cg9qtTtOqzXCUiOKlb6/9RKkuvVrV",
"UfGXpMDzFm7EOExQAg4lt4VcXWFhqZwnXz2Yb1nl/lN4DRV86HeBhhpKhRQpTUvqjtcg4td8fZSIKxRy",
"KZXm+C91PcyQ2LOyzKDxQShtf5q7E1yC0ruSXFpWlHUJUFG9acsQHl5Jxey8EfrpWVVFI97tkVV3EWwQ",
"xZEfaF87rSc+8dFgg7S8jtCwjYi6B1o5ex/+OhKaSfH9QIrxyLq7a77vMEyDhbtFYnLoPGtqMQeZ8TJU",
"AD08J1L1zkgzsT0qoBXst5n4kvA/pHuYc12n3qJNRm/yuZvMinbJ/ay6nPi55OuJHkL2JFnen1jwMI0M",
"Sx1E5CcLIuQ5KqRSDzp/7JKYRZolHa+7iOe4Q3tNNa/UN54bcPe2/jvwlMv9gDKQxj8Ql+YrMZ2nCzAX",
"snLQtmDIZItrExHxzyQ2TWla2fnZRAefkYYWn2khEXeqtmWMQ9J5QYgZNcVJRRo6wCehiUOa644f+aY6",
"rEkP+wkW1PqNu8XFUjQuyGKYVuFDJ9c8Uj65QFrfZOI/kKxq8sZ/f6Lqrsdh6ACdhY5Ig9ZWaKj0kNZW",
"mGIwVGRvyF/7r2dvXr0Q3GFJXMOO/f1kXs4J+ivw3UI47Gup5RLsNHh5s24KZQxCnb0Pfx2xPGOM6wFx",
"MSba2Vwi+SBytEHF3eRoDqdnIag5TojeEYAhIfrMXbOISruFnEQpyDJ1JXVpFgvxaGlkVYgS5iqEZymr",
"A3+RJTr+ruBMhyZjoOB72x6HfrWc3FVbHV5OW9Mq10R0y3AVEsqH+DhErkmQkmil5FpqlBtkdyOxI7hB",
"0neFdkb+Pmf0D5Lvpw3WR+gJTWkBrKEMuxpubyHcVQBmiVVpD9bWm89Dri91OGcMDVz42Ke3pa8ikF/I",
"pjZIox1tutx/3p3snPp96o28YKZlJmdaZ/5Vrpt/HwVATKRHOZULVuuybTD1uzzlIySuWoHxhVpVScO2",
"I/ZE47o/lAt3wDRNzNL7tiB45XczH7rIO5u3LQKyjvbLtv49f+WxeNTUyRVNdXyt+S+urNHUUAx/x/XC",
"nM702+taHwuqdeb7kzsNB27Rb2DY0TextQd1aSUPmhvLk2xIOi6araZ65HecVeCFN/V8BWVIMo+l/kXb",
"6VCFwsmkC4DSzoMsjy+ECwSo2cJQz4QI+aGOCbwg52HDndvqrH0SWKGtuN+jsYF2ld7EZYQcOV5c3Id2",
"QYVwNW6GC7dPTmN6QKwVWIEsp+IH44WsKrONaImLoUZNuZRf3LNjucL7wCcY0ueUetRSDRl0pEGV7u6d",
"sDKcjEqmcyozSGtLswC2d5b3YOxk0Gc6B8Vvz7tIzu42X0LV/Iv358TF9qHxWC30ntHweBBgD5uPTb3/",
"yHhnIMKM2HwD9pSCcugUfGkprp9aMJtTWftVaAo4GASlQ3wXO9lE2uXylVCp0iY/dQR42/FDPKIcjjZy",
"1XxCMmglS6pypDwQztxu7c+2l6CyArHwuL0FLsgJZfejXs9iafkuhRalcmjkRw2uGPj0egAhxfOIFihP",
"8fNwO0Be5j03zwIKP8kBaHK/wsjTl3aTv3xLoQx3hQ6SYq3pOllqI4BbTzcrITfPwG8BtJDUS4DVXXMi",
"s+yopumQ6qKbSkflR4R+BeMXPFSpwi16PnqcsCEfPU5zZ/Dt8jzuMfk0Xhg7dJQo/Xz1+xbapbKDLPKj",
"Tm9U7/aHi2ZG0byhwMVbYqkEbLYj6ToVL8MN7HRNM1nRVFNTwcILU/unorGry+a2dnwhtubgqBc12kRB",
"TFdMD930j3NbUy9XYjrlUrJwY34ROmjXLl/D0zCtPWZrXlTSrU4d4EueXYQRN9sjLG0bt/SO+wMc+Hm4",
"5oWy3yvnD1QPN0RBjbs9X3b0++Yi6g44Lqh0l3mHs9jW5qabSdC2g9Nl9Dlj6yY0YpResiMZ3ufjQno7",
"VBRbWEuVCTCGHoifM0TRtHm8W7DifuI3x+rQE+RCWkcuhYO3++ndbySblRvpXDyzrSQ1a34rZnJOHiB9",
"n0/tRmwdrNF+uHpqaunbGWot34W+dE+ePCkOt6n7JBWuhJkxtu3PdNUDN+gOm/YoRjf+9OTJ4y/f2kXD",
"dZAknwkLy7qSlu3bfGBsKi7hnWdF6Wur0VfacH22U78BY+ep4E4TfMgV3hTaxN4RD66i0ai7Xx19RAt/",
"TM7y/dqyz8NAA8IRd+T3rYepS8owEzSNj5AHmuy6nkWLFlrT/zFeBaTL8HHngneidOXQtjNV7alQfYjW",
"ngY1wLeYo6wPhE8Rvuaop71HseXLbiOlgtooEUgX//VdES8Knu0EvPOgKRFgmH9erfm8aEzjhPtngH/W",
"zjvuZvlv79bVZ2/cg6QbG/fcidf/+PuUDBS++/T2+UVbnUdZHByt5MuNw80Bp0pnM0WERgqpql0RLuqj",
"pvEDtr7S3lD4Kp6DKF+lJznh/aSrd0wuGT6GilHLvaBniHLmw5okOzkg2gkXtGHDgmpsqDaGqzDVUulC",
"LKTzpwtjtzIWVbeA0UXgJt5KGcILNvF8ci5PDBq0+AoXNfRSGBnlqr0ypU06yZbW2iXkXaBPe/TNAemj",
"GTNf9lH4WWSBT8+aTRJYINHAmiYi9gBvMLvtHewWiWKXeof28KJSc8+2g4UlaKIjvRThTyjZiJ6Ki+QS",
"wgBOmppGhxouJo90isqVD6nUGu6DsF/pf5H1fZB1pha877p/8RXZHc4YqE//cvziY1lXuruQoeyQ0Gih",
"ZA3V3PvZzazMZ+BxnlSafnf/KVndC1A/cWL7GAZOElj/SXg1Xhw5JqCXXNHZjeNRo0n0BNObU8ffznEN",
"SGI+f8nnq+SOz+RKIFI+KnsBVbg41GxjNDf4nmhiDnXUu2jw8CnkUXN57djuEBG6Lz8S14CK+3W2MPb6",
"waybIjuQHjNGJxJ7rNgoVq2EO4Bz1YqkqduoSpLTxAxRNG1IufNeRxaT64MEpnQdjPpkgNC6wegbsI6v",
"3uC0KOeNaarouplHfPdBSJJLbti7fsynH4mYOkmlemwI20kAaW6JVn7fykoZka+N4+IqriJoLx4ONwPa",
"0LshrCmOL+kCo+Z6whyLfmvs9UWbvPWv4qWjgr25Amg4Kv4LzC7M/Bp8COvxPWZpzlEYoxBS0AUDwq2g",
"qtB4QGugDRpGQ5XouHLoNJP4VQtuMNma4THFm/q5M5mxvbGNbcnipGTa88321U5sLLJsGfxfuomj9ps6",
"WCbIZeAKIasKfwhB+pjuznPyNY7RutHh271Xg7vi1G9A92PS7TPNw5Wp+N4Zkd5n1AQwIKAH3qkY9uB1",
"EmuEepW5KUF8/eTJkyaPFF+P96KG6kEL0h2MYyaXLo0IZcZrkAal4uErlfKD0qVKdx50P1j6FTPxnvFK",
"TeVasmwJNt6K+fuO+NfJPTZZHm96/e3b+XzpOykepNVBX7804PSJz+Z5NbfoPGRHpjjHUFem8DxeMjP9",
"p93hDx/+fwAAAP//",
}
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
+6
View File
@@ -28,6 +28,7 @@ type Server struct {
agents *agents.Manager
notifier *push.Notifier
hub *hub
terminals *terminals
startedAt time.Time
spa fs.FS
@@ -45,6 +46,7 @@ func New(dataDir string, spa fs.FS) (*Server, error) {
s := &Server{
hub: newHub(),
terminals: newTerminals(),
startedAt: time.Now().UTC(),
spa: spa,
users: map[string]string{},
@@ -79,9 +81,13 @@ func (s *Server) publish(ev api.Event) {
s.hub.publish(ev)
s.notifier.Observe(ev)
s.agents.Observe(ev)
if (ev.Kind == "task.merged" || ev.Kind == "task.discarded") && ev.TaskId != nil {
s.terminals.close(*ev.TaskId)
}
}
func (s *Server) Close() error {
s.terminals.closeAll()
s.agents.Close()
s.notifier.Close()
return s.store.Close()
+5
View File
@@ -138,6 +138,11 @@ func TestServesEmbeddedSPA(t *testing.T) {
"/dist/mermaid.js": "text/javascript",
"/vendor/mermaid.min.js": "text/javascript",
"/vendor/mermaid.LICENSE": "",
"/dist/terminal.js": "text/javascript",
"/vendor/xterm.mjs": "text/javascript",
"/vendor/addon-fit.mjs": "text/javascript",
"/vendor/xterm.css": "text/css",
"/vendor/xterm.LICENSE": "",
"/fonts/atkinson-hyperlegible-mono-latin-wght-normal.woff2": "font/woff2",
"/fonts/LICENSE": "",
} {
+291
View File
@@ -0,0 +1,291 @@
package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/exec"
"sync"
"syscall"
"github.com/coder/websocket"
"github.com/creack/pty"
"g.fc.run/misc/byre/internal/api"
)
const (
terminalBacklog = 256 << 10
terminalClientBuf = 256
terminalReadLimit = 1 << 20
terminalMaxSize = 1 << 15
terminalExited = websocket.StatusCode(4000)
)
type terminals struct {
mu sync.Mutex
byTask map[string]*terminal
}
type terminal struct {
ptmx *os.File
cmd *exec.Cmd
mu sync.Mutex
backlog []byte
clients map[chan []byte]bool
exit string
exited bool
}
func newTerminals() *terminals {
return &terminals{byTask: map[string]*terminal{}}
}
func (ts *terminals) attach(t api.Task, size api.TerminalSize) (*terminal, error) {
ts.mu.Lock()
defer ts.mu.Unlock()
term := ts.byTask[t.Id]
if term != nil {
return term, nil
}
shell := os.Getenv("SHELL")
if shell == "" {
return nil, errors.New("SHELL is not set in byre's environment")
}
cmd := exec.Command(shell, "-l")
cmd.Dir = t.WorktreePath
cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", fmt.Sprintf("PORT=%d", t.PortBase))
ptmx, err := pty.StartWithSize(cmd, winsize(size))
if err != nil {
return nil, fmt.Errorf("start %s: %w", shell, err)
}
term = &terminal{ptmx: ptmx, cmd: cmd, clients: map[chan []byte]bool{}}
ts.byTask[t.Id] = term
slog.Info("terminal started", "task", t.Id, "shell", shell, "pid", cmd.Process.Pid)
go func() {
term.pump()
ts.mu.Lock()
if ts.byTask[t.Id] == term {
delete(ts.byTask, t.Id)
}
ts.mu.Unlock()
slog.Info("terminal ended", "task", t.Id, "exit", term.exit)
}()
return term, nil
}
func (ts *terminals) close(taskID string) {
ts.mu.Lock()
term := ts.byTask[taskID]
delete(ts.byTask, taskID)
ts.mu.Unlock()
if term != nil {
term.kill()
}
}
func (ts *terminals) closeAll() {
ts.mu.Lock()
all := ts.byTask
ts.byTask = map[string]*terminal{}
ts.mu.Unlock()
for _, term := range all {
term.kill()
}
}
func winsize(size api.TerminalSize) *pty.Winsize {
return &pty.Winsize{Cols: uint16(size.Cols), Rows: uint16(size.Rows)}
}
func validSize(size api.TerminalSize) bool {
return size.Cols >= 1 && size.Rows >= 1 && size.Cols <= terminalMaxSize && size.Rows <= terminalMaxSize
}
func (term *terminal) pump() {
buf := make([]byte, 32<<10)
for {
n, err := term.ptmx.Read(buf)
if n > 0 {
term.output(bytes.Clone(buf[:n]))
}
if err != nil {
break
}
}
err := term.cmd.Wait()
term.ptmx.Close()
exit := fmt.Sprint(err)
if term.cmd.ProcessState != nil {
exit = term.cmd.ProcessState.String()
}
term.mu.Lock()
defer term.mu.Unlock()
term.exit = exit
term.exited = true
for c := range term.clients {
close(c)
}
term.clients = nil
}
func (term *terminal) output(b []byte) {
term.mu.Lock()
defer term.mu.Unlock()
term.backlog = append(term.backlog, b...)
if len(term.backlog) > 2*terminalBacklog {
term.backlog = bytes.Clone(term.backlog[len(term.backlog)-terminalBacklog:])
}
for c := range term.clients {
select {
case c <- b:
default:
delete(term.clients, c)
close(c)
}
}
}
func (term *terminal) subscribe() ([]byte, chan []byte) {
term.mu.Lock()
defer term.mu.Unlock()
if term.exited {
return nil, nil
}
c := make(chan []byte, terminalClientBuf)
term.clients[c] = true
return bytes.Clone(term.backlog[max(0, len(term.backlog)-terminalBacklog):]), c
}
func (term *terminal) unsubscribe(c chan []byte) {
term.mu.Lock()
defer term.mu.Unlock()
if term.clients[c] {
delete(term.clients, c)
close(c)
}
}
func (term *terminal) exitStatus() (string, bool) {
term.mu.Lock()
defer term.mu.Unlock()
return term.exit, term.exited
}
func (term *terminal) kill() {
err := syscall.Kill(-term.cmd.Process.Pid, syscall.SIGHUP)
if err != nil && !errors.Is(err, syscall.ESRCH) {
slog.Warn("hang up terminal", "pid", term.cmd.Process.Pid, "err", err)
}
term.ptmx.Close()
}
func (s *Server) GetTaskTerminal(w http.ResponseWriter, r *http.Request, taskID string, params api.GetTaskTerminalParams) {
t, _, ok := s.activeTask(w, r, taskID)
if !ok {
return
}
size := api.TerminalSize{Cols: params.Cols, Rows: params.Rows}
if !validSize(size) {
writeError(w, http.StatusBadRequest, fmt.Errorf("cols and rows must be between 1 and %d", terminalMaxSize))
return
}
conn, err := websocket.Accept(w, r, nil)
if err != nil {
slog.Warn("accept terminal websocket", "task", taskID, "err", err)
return
}
defer conn.CloseNow()
conn.SetReadLimit(terminalReadLimit)
term, err := s.terminals.attach(t, size)
if err != nil {
slog.Error("start terminal", "task", taskID, "err", err)
conn.Close(websocket.StatusInternalError, "couldn't start the shell; see byre's log")
return
}
backlog, out := term.subscribe()
if out == nil {
exit, _ := term.exitStatus()
conn.Close(terminalExited, exit)
return
}
defer term.unsubscribe(out)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
go func() {
defer cancel()
terminalInput(ctx, conn, term)
}()
if len(backlog) > 0 {
err = conn.Write(ctx, websocket.MessageBinary, backlog)
if err != nil {
return
}
}
for {
select {
case <-ctx.Done():
return
case b, open := <-out:
if open {
err = conn.Write(ctx, websocket.MessageBinary, b)
if err != nil {
return
}
continue
}
exit, exited := term.exitStatus()
if exited {
conn.Close(terminalExited, exit)
return
}
conn.Close(websocket.StatusTryAgainLater, "fell behind the terminal's output")
return
}
}
}
func terminalInput(ctx context.Context, conn *websocket.Conn, term *terminal) {
for {
typ, b, err := conn.Read(ctx)
if err != nil {
return
}
if typ == websocket.MessageBinary {
_, err = term.ptmx.Write(b)
if err != nil {
slog.Debug("terminal input", "err", err)
}
continue
}
size := api.TerminalSize{}
err = json.Unmarshal(b, &size)
if err != nil || !validSize(size) {
conn.Close(websocket.StatusUnsupportedData, "resize must be a TerminalSize")
return
}
err = pty.Setsize(term.ptmx, winsize(size))
if err != nil {
slog.Debug("terminal resize", "err", err)
}
}
}
+147
View File
@@ -0,0 +1,147 @@
package server_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"g.fc.run/misc/byre/internal/api"
)
type termConn struct {
t *testing.T
conn *websocket.Conn
seen strings.Builder
}
func (h *harness) openTerminal(taskID string) *termConn {
h.t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
header := http.Header{}
header.Set("Tailscale-User-Login", login)
header.Set("Tailscale-User-Name", "Alice")
url := "ws" + strings.TrimPrefix(h.url, "http") + "/api/tasks/" + taskID + "/terminal?cols=80&rows=24"
conn, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{HTTPHeader: header})
if err != nil {
h.t.Fatalf("dial terminal: %v", err)
}
h.t.Cleanup(func() { conn.CloseNow() })
return &termConn{t: h.t, conn: conn}
}
func (c *termConn) send(text string) {
c.t.Helper()
err := c.conn.Write(context.Background(), websocket.MessageBinary, []byte(text))
if err != nil {
c.t.Fatalf("write terminal: %v", err)
}
}
func (c *termConn) waitFor(want string) {
c.t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for !strings.Contains(c.seen.String(), want) {
_, b, err := c.conn.Read(ctx)
if err != nil {
c.t.Fatalf("waiting for %q: %v; seen %q", want, err, c.seen.String())
}
c.seen.Write(b)
}
}
func (c *termConn) waitClosed() websocket.CloseError {
c.t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for {
_, b, err := c.conn.Read(ctx)
if err == nil {
c.seen.Write(b)
continue
}
ce := websocket.CloseError{}
if !errors.As(err, &ce) {
c.t.Fatalf("want a close frame, got %v", err)
}
return ce
}
}
func TestTaskTerminal(t *testing.T) {
t.Setenv("SHELL", "/bin/sh")
h := newHarness(t)
p := h.createProject("demo")
task := api.Task{}
h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "terminal"}, &task)
wt, err := filepath.EvalSymlinks(task.WorktreePath)
if err != nil {
t.Fatalf("resolve worktree: %v", err)
}
header := http.Header{}
header.Set("Tailscale-User-Login", login)
header.Set("Tailscale-User-Name", "Alice")
header.Set("Origin", "https://elsewhere.example")
_, resp, err := websocket.Dial(context.Background(), "ws"+strings.TrimPrefix(h.url, "http")+"/api/tasks/"+task.Id+"/terminal?cols=80&rows=24", &websocket.DialOptions{HTTPHeader: header})
if err == nil || resp == nil || resp.StatusCode != http.StatusForbidden {
t.Fatalf("cross-origin terminal: want 403, got %v %v", resp, err)
}
a := h.openTerminal(task.Id)
a.send("echo \"$PORT:$(pwd -P)\"\n")
a.waitFor(fmt.Sprintf("%d:%s", task.PortBase, wt))
a.send("echo byre-$((40+2))\n")
a.waitFor("byre-42")
size, err := json.Marshal(api.TerminalSize{Cols: 100, Rows: 30})
if err != nil {
t.Fatalf("marshal size: %v", err)
}
err = a.conn.Write(context.Background(), websocket.MessageText, size)
if err != nil {
t.Fatalf("resize: %v", err)
}
a.send("stty size\n")
a.waitFor("30 100")
b := h.openTerminal(task.Id)
b.waitFor("byre-42")
b.send("exit\n")
for _, c := range []*termConn{a, b} {
ce := c.waitClosed()
if ce.Code != 4000 || ce.Reason != "exit status 0" {
t.Fatalf("close after exit: %d %q", ce.Code, ce.Reason)
}
}
c := h.openTerminal(task.Id)
c.send("echo fresh\n")
c.waitFor("fresh")
if strings.Contains(c.seen.String(), "byre-42") {
t.Fatalf("new shell replayed the old one's output: %q", c.seen.String())
}
code := h.do("POST", "/api/tasks/"+task.Id+"/discard", nil, nil)
if code != http.StatusOK {
t.Fatalf("discard: %d", code)
}
ce := c.waitClosed()
if ce.Code != 4000 {
t.Fatalf("close after discard: %d %q", ce.Code, ce.Reason)
}
code = h.do("GET", "/api/tasks/"+task.Id+"/terminal?cols=80&rows=24", nil, nil)
if code != http.StatusConflict {
t.Fatalf("terminal of a discarded task: want 409, got %d", code)
}
}