Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nextchanges/air/7601.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Strip terminal control characters from the MLflow run, experiment and user names shown by `air list` and `air get`, so a run name cannot clear or retitle the terminal of whoever lists it. ([#7601](https://github.com/databricks/cli/pull/7601))
1 change: 1 addition & 0 deletions acceptance/air/control-chars/out.test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Cloud = false
54 changes: 54 additions & 0 deletions acceptance/air/control-chars/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@

=== get (text): control characters are stripped from the Metadata box

>>> [CLI] air get 123

╭─ Metadata ─────────────────────────────────────────────────────╮
│ │
│ Run ID 123 │
│ Status ● SUCCESS │
│ Submitted 2023-11-14 22:13 UTC │
│ Retries 0 │
│ Max Retries 0 │
│ Duration 12s │
│ Experiment my-exp │
│ MLflow Run my-run │
│ User [email protected] │
│ Accelerators 1x A10 │
│ Environment N/A │
│ │
╰────────────────────────────────────────────────────────────────╯

Run URL: [DATABRICKS_URL]/jobs/runs/123?o=[WORKSPACE_ID]
MLflow URL: [DATABRICKS_URL]/ml/experiments/exp1/runs/run1

=== list (text): control characters are stripped from the table

>>> [CLI] air list --all-users
Run ID Experiment Status Started Duration Progress MLflow User Accelerators
123 my-exp ● SUCCESS [TIMESTAMP] 12s - my-run [email protected] 1x A10

=== list (json): strings are passed through as the server sent them

>>> [CLI] air list --all-users -o json
{
"v": 1,
"ts": "[TIMESTAMP]",
"data": {
"runs": [
{
"run_id": "123",
"run_name": "qwen\u001b[2J-train",
"user": "user\u001b[[email protected]",
"status": "SUCCESS",
"started_at": "[TIMESTAMP]",
"is_sweep": false,
"experiment_name": "my\u001b]0;pwned\u0007-exp",
"duration_seconds": 12,
"accelerator_type": "GPU_1xA10",
"num_accelerators": 1,
"dashboard_url": "[DATABRICKS_URL]/jobs/runs/123?o=[WORKSPACE_ID]"
}
]
}
}
8 changes: 8 additions & 0 deletions acceptance/air/control-chars/script
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
title "get (text): control characters are stripped from the Metadata box"
trace $CLI air get 123

title "list (text): control characters are stripped from the table"
trace $CLI air list --all-users

title "list (json): strings are passed through as the server sent them"
trace $CLI air list --all-users -o json
69 changes: 69 additions & 0 deletions acceptance/air/control-chars/test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Server-supplied names carrying terminal control characters: an OSC
# set-window-title sequence, a CSI clear-screen sequence, an SGR colour code and
# a carriage return. The text views must drop them; JSON output keeps the strings
# as the server sent them (encoded as \u001b etc. by the JSON encoder).
Env.DATABRICKS_CACHE_ENABLED = "false"

[[Server]]
Pattern = "HEAD /"
Response.Body = ''

# Experiment and creator names with embedded escapes, for `air get` and `air list`.
[[Server]]
Pattern = "GET /api/2.2/jobs/runs/get"
Response.Body = '''
{
"run_id": 123,
"run_name": "qwen\u001b[2J-train",
"creator_user_name": "user\u001b[[email protected]",
"start_time": 1700000000000,
"end_time": 1700000012000,
"state": {"life_cycle_state": "TERMINATED", "result_state": "SUCCESS"},
"tasks": [{
"task_key": "train",
"run_id": 456,
"attempt_number": 0,
"ai_runtime_task": {
"experiment": "/Users/[email protected]/my\u001b]0;pwned\u0007-exp",
"deployments": [{"compute": {"accelerator_type": "GPU_1xA10", "accelerator_count": 1}}]
}
}]
}
'''

[[Server]]
Pattern = "GET /api/2.2/jobs/runs/list"
Response.Body = '''
{
"runs": [{
"run_id": 123,
"run_name": "qwen\u001b[2J-train",
"creator_user_name": "user\u001b[[email protected]",
"start_time": 1700000000000,
"end_time": 1700000012000,
"state": {"life_cycle_state": "TERMINATED", "result_state": "SUCCESS"},
"tasks": [{
"task_key": "train",
"run_id": 456,
"attempt_number": 0,
"ai_runtime_task": {
"experiment": "/Users/[email protected]/my\u001b]0;pwned\u0007-exp",
"deployments": [{"compute": {"accelerator_type": "GPU_1xA10", "accelerator_count": 1}}]
}
}]
}]
}
'''

[[Server]]
Pattern = "GET /api/2.2/jobs/runs/get-output"
Response.Body = '''
{"ai_runtime_task_output": {"mlflow_experiment_id": "exp1", "mlflow_run_id": "run1"}}
'''

# The MLflow run name carries all three kinds of control character.
[[Server]]
Pattern = "GET /api/2.0/mlflow/runs/get"
Response.Body = '''
{"run": {"info": {"run_name": "\u001b]0;pwned\u0007my\u001b[2J-run\r"}}}
'''
9 changes: 5 additions & 4 deletions cmd/air/list_tui_render.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,12 @@ func listRowStatus(r listRow) string {
return r.Status
}

// cell renders one padded, colored cell. The text is truncated to width, then
// padded with (background-only) spaces so columns align even when the text is
// styled or hyperlinked.
// cell renders one padded, colored cell. The text is stripped of control
// characters (it may come from the server, or from a cache written before this
// stripping existed), truncated to width, then padded with (background-only)
// spaces so columns align even when the text is styled or hyperlinked.
func (s listStyles) cell(base lipgloss.Style, text string, width int, fg lipgloss.Color, right, underline bool, link string) string {
text = truncate(text, width)
text = truncate(stripControl(text), width)
style := base.Foreground(fg)
if underline {
style = style.Underline(true)
Expand Down
23 changes: 23 additions & 0 deletions cmd/air/list_tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"testing"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/env"
Expand Down Expand Up @@ -238,6 +239,28 @@ func TestRenderRowHyperlinks(t *testing.T) {
assert.NotContains(t, plain, "\x1b]8;;", "no links when links are disabled")
}

func TestRenderRowStripsControlCharacters(t *testing.T) {
r, _ := cmdio.NewRenderer(cmdio.MockDiscard(t.Context()), io.Discard)
styles := newListStyles(r)
row := listRow{
RunID: "1", Status: "SUCCESS", Duration: "-", Accelerators: "-",
Experiment: "\x1b]0;pwned\x07exp\x1b[2Jeriment",
User: "user\[email protected]",
MLflowURL: "https://h/ml/experiments/E/runs/rid",
MLflowLabel: "my\x1b[31m-run",
}
cols := computeListCols([]listRow{row})

out := styles.renderRow(cols, row, false, false)
assert.Contains(t, out, "experiment")
assert.Contains(t, out, "[email protected]")
assert.Contains(t, out, "my-run")
assert.NotContains(t, out, "\x1b")
assert.NotContains(t, out, "\r")
// Stripping happens before width is measured, so the columns stay aligned.
assert.Equal(t, cols.experiment, lipgloss.Width(stripControl(row.Experiment)))
}

func TestListModelInfoKeyOpensDetail(t *testing.T) {
r, _ := cmdio.NewRenderer(cmdio.MockDiscard(t.Context()), io.Discard)
f := &runFetcher{ctx: t.Context(), w: newTestWorkspaceClient(t, "https://x.test")}
Expand Down
5 changes: 3 additions & 2 deletions cmd/air/mlflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@ func mlflowExperimentURL(host string, ids *mlflowIdentifiers) string {

// fetchMLflowRunName fetches a run's MLflow run_name via the MLflow REST API,
// returning "" if it can't be obtained. Best-effort, like the rest of the MLflow
// enrichment.
// enrichment. The name is stripped of control characters here, before it is
// shortened to a column width or cached, so no caller can show it raw.
func fetchMLflowRunName(ctx context.Context, w *databricks.WorkspaceClient, mlflowRunID string) string {
resp, err := w.Experiments.GetRun(ctx, ml.GetRunRequest{RunId: mlflowRunID})
if err != nil {
Expand All @@ -152,7 +153,7 @@ func fetchMLflowRunName(ctx context.Context, w *databricks.WorkspaceClient, mlfl
if resp.Run == nil || resp.Run.Info == nil {
return ""
}
return resp.Run.Info.RunName
return stripControl(resp.Run.Info.RunName)
}

// mlflowRunLabel is the text shown for the MLflow Run cell: the run's name, or
Expand Down
6 changes: 6 additions & 0 deletions cmd/air/mlflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,4 +229,10 @@ func TestFetchMLflowRunName(t *testing.T) {
srv := mlflowServer(`{}`)
assert.Empty(t, fetchMLflowRunName(ctx, newTestWorkspaceClient(t, srv.URL), "run1"))
})

t.Run("strips control characters from the name", func(t *testing.T) {
// A set-window-title OSC, a clear-screen CSI and a carriage return.
srv := mlflowServer(`{"run":{"info":{"run_name":"\u001b]0;pwned\u0007sunny\u001b[2J-cat\r-42"}}}`)
assert.Equal(t, "sunny-cat-42", fetchMLflowRunName(ctx, newTestWorkspaceClient(t, srv.URL), "run1"))
})
}
7 changes: 6 additions & 1 deletion cmd/air/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,8 +376,13 @@ func renderBox(p palette, title, body string) string {

// renderFields draws the two-column summary: muted labels right-padded to the
// longest one, neutral values, a status-colored Status, and blue Run ID / MLflow
// Run hyperlinks.
// Run hyperlinks. The experiment, MLflow run and user values are server-supplied
// names, so they are stripped of control characters before they reach the
// terminal.
func renderFields(p palette, colorOn bool, v runView) string {
v.experiment = stripControl(v.experiment)
v.mlflowLabel = stripControl(v.mlflowLabel)
v.user = stripControl(v.user)
status := statusStyle(p, v.status).Render("● " + v.status)
rows := []string{
field(p, "Run ID", link(colorOn, p.blue, v.runID, v.dashboardURL)),
Expand Down
23 changes: 23 additions & 0 deletions cmd/air/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,29 @@ func TestRenderFields(t *testing.T) {
assert.NotContains(t, out, "\n\n")
}

func TestRenderFieldsStripsControlCharacters(t *testing.T) {
p := asciiPalette(t)
out := renderFields(p, false, runView{
runID: "1",
status: "SUCCESS",
submitted: "2026-06-03 04:17 UTC",
maxRetries: "3",
duration: "1m 13s",
experiment: "\x1b]0;pwned\x07exp\x1b[2Jeriment",
mlflowLabel: "run\rname",
mlflowURL: "https://h.test/ml/experiments/E1/runs/R1",
user: "user\x1b[[email protected]",
accelerators: "1x A10",
environment: "4",
})

assert.Contains(t, out, "Experiment experiment")
assert.Contains(t, out, "MLflow Run runname")
assert.Contains(t, out, "User [email protected]")
assert.NotContains(t, out, "\x1b")
assert.NotContains(t, out, "\r")
}

func TestLink(t *testing.T) {
p := asciiPalette(t)
// Color off: the bare label, no URL.
Expand Down
29 changes: 29 additions & 0 deletions cmd/air/sanitize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package aircmd

import (
"strings"
"unicode"

"github.com/charmbracelet/x/ansi"
)

// stripControl removes terminal escape sequences (CSI, OSC, DCS, ...) and C0/C1
// control characters from s, so a server-supplied string such as an MLflow run
// name can be shown without letting whoever chose it clear the screen, retitle
// the window, write the clipboard or overwrite a line of output. Printable text,
// including non-ASCII, is kept. Apply it to display values only: JSON output and
// link targets are left as the server sent them.
func stripControl(s string) string {
// Drop whole escape sequences first, so "\x1b[2J" does not leave "[2J"
// behind once the ESC is removed below.
s = ansi.Strip(s)
if !strings.ContainsFunc(s, unicode.IsControl) {
return s
}
return strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, s)
}
33 changes: 33 additions & 0 deletions cmd/air/sanitize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package aircmd

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestStripControl(t *testing.T) {
tests := []struct {
name, in, want string
}{
{"plain text is unchanged", "sunny-cat-42", "sunny-cat-42"},
{"non-ASCII text is kept", "résumé 日本語 🚀", "résumé 日本語 🚀"},
{"OSC set-title terminated by BEL", "\x1b]0;pwned\x07name", "name"},
{"OSC set-title terminated by ST", "\x1b]0;pwned\x1b\\name", "name"},
{"OSC 52 clipboard write", "a\x1b]52;c;cHduZWQ=\x07b", "ab"},
{"CSI clear screen", "a\x1b[2Jb", "ab"},
{"CSI color", "\x1b[31mred\x1b[0m", "red"},
{"carriage return", "real-name\rfake", "real-namefake"},
{"tab and newline", "a\tb\nc", "abc"},
{"DEL", "a\x7fb", "ab"},
{"C1 control", "a\u0085b\u009bc", "abc"},
{"two-byte escape sequence", "a\x1bbz", "az"},
{"trailing ESC", "a\x1b", "a"},
{"NUL", "a\x00b", "ab"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, stripControl(tt.in))
})
}
}
Loading