···434434 body = ""
435435 }
436436437437+ // Buildkite injects per-line timestamp metadata as
438438+ // ANSI APC sequences (ESC "_" "bk;t=<ms>" BEL) and
439439+ // some renderers downstream don't recognise the APC
440440+ // envelope, leaking the inner "_bk;t=…" payload into
441441+ // the displayed text. Strip them here so consumers
442442+ // only ever see the actual log content.
443443+ body = stripTerminal(body)
444444+437445 for _, line := range strings.Split(strings.TrimRight(body, "\n"), "\n") {
438446 if line == "" {
439447 // Skip the leading empty entry that
···651659 return strings.TrimPrefix(ref, prefix)
652660 }
653661 return ref
662662+}
663663+664664+// stripTerminal removes ANSI/ECMA-48 escape sequences from a log
665665+// payload, leaving only the displayable text. We need this because
666666+// Buildkite ships its plain-text log API with the agent's full
667667+// terminal output — per-line timestamp APC envelopes
668668+// (`ESC _ "bk;t=<unix-ms>" BEL`), CSI colour codes, clear-to-EOL
669669+// (`ESC [ K`), OSC title sets, etc. — and our consumers are not
670670+// terminal emulators; they render the bytes verbatim.
671671+//
672672+// We recognise the standard escape families described by ECMA-48:
673673+//
674674+// - CSI: ESC '[' parameters intermediates final
675675+// - OSC/DCS/APC/SOS/PM: ESC (']'|'P'|'_'|'X'|'^') … (BEL | ESC '\')
676676+// - everything else: ESC <single byte>
677677+//
678678+// As a safety belt we also strip the bare "_bk;t=<digits>" residue
679679+// that appears when an upstream processor has stripped the ESC/BEL
680680+// framing without understanding the APC envelope inside it.
681681+//
682682+// We should use libghostty for obvious reasons.
683683+func stripTerminal(s string) string {
684684+ if !strings.ContainsAny(s, "\x1b_") {
685685+ return s
686686+ }
687687+ var b strings.Builder
688688+ b.Grow(len(s))
689689+ for i := 0; i < len(s); {
690690+ if s[i] == 0x1b && i+1 < len(s) {
691691+ switch s[i+1] {
692692+ case '[':
693693+ // CSI: parameter bytes 0x30-0x3F, then
694694+ // intermediate bytes 0x20-0x2F, then a
695695+ // single final byte 0x40-0x7E. Anything
696696+ // that doesn't conform we drop minimally
697697+ // (just the ESC) so we don't swallow
698698+ // legitimate text.
699699+ j := i + 2
700700+ for j < len(s) && s[j] >= 0x30 && s[j] <= 0x3F {
701701+ j++
702702+ }
703703+ for j < len(s) && s[j] >= 0x20 && s[j] <= 0x2F {
704704+ j++
705705+ }
706706+ if j < len(s) && s[j] >= 0x40 && s[j] <= 0x7E {
707707+ i = j + 1
708708+ continue
709709+ }
710710+ i += 2
711711+ continue
712712+ case ']', 'P', '_', 'X', '^':
713713+ // OSC/DCS/APC/SOS/PM: terminated by BEL or
714714+ // ST (ESC '\'). Drop the entire envelope.
715715+ j := i + 2
716716+ for j < len(s) {
717717+ if s[j] == 0x07 {
718718+ j++
719719+ break
720720+ }
721721+ if s[j] == 0x1b && j+1 < len(s) && s[j+1] == '\\' {
722722+ j += 2
723723+ break
724724+ }
725725+ j++
726726+ }
727727+ i = j
728728+ continue
729729+ default:
730730+ // Two-byte escape (RIS, DECSC, charset
731731+ // selection, …). Drop both bytes.
732732+ i += 2
733733+ continue
734734+ }
735735+ }
736736+ // Bare residue: "_bk;t=<digits>" with no ESC/BEL framing.
737737+ if s[i] == '_' && strings.HasPrefix(s[i:], "_bk;t=") {
738738+ j := i + len("_bk;t=")
739739+ for j < len(s) && s[j] >= '0' && s[j] <= '9' {
740740+ j++
741741+ }
742742+ if j > i+len("_bk;t=") {
743743+ i = j
744744+ continue
745745+ }
746746+ }
747747+ b.WriteByte(s[i])
748748+ i++
749749+ }
750750+ return b.String()
654751}
655752656753// sendLine pushes one LogLine into out, returning false if ctx
+84
provider_buildkite_test.go
···505505 })
506506 }
507507}
508508+509509+// TestStripTerminal exercises both shapes of Buildkite's inline
510510+// timestamp metadata: the well-formed APC envelope ESC _ … BEL, and
511511+// the bare "_bk;t=<digits>" residue we sometimes see when an upstream
512512+// processor strips control bytes without understanding the envelope.
513513+func TestStripTerminal(t *testing.T) {
514514+ cases := []struct {
515515+ name string
516516+ in string
517517+ want string
518518+ }{
519519+ {"empty", "", ""},
520520+ {"no metadata", "hello world\n", "hello world\n"},
521521+ {
522522+ "framed apc",
523523+ "\x1b_bk;t=1777680702553\x07~~~ Running agent environment hook\n",
524524+ "~~~ Running agent environment hook\n",
525525+ },
526526+ {
527527+ "multiple framed apc per line",
528528+ "\x1b_bk;t=1\x07a\x1b_bk;t=2\x07b\n",
529529+ "ab\n",
530530+ },
531531+ {
532532+ "bare residue",
533533+ "_bk;t=1777680702553~~~ Running agent environment hook\n",
534534+ "~~~ Running agent environment hook\n",
535535+ },
536536+ {
537537+ "bare residue mid-line",
538538+ "remote: Counting objects: 0% _bk;t=1777680705851\n",
539539+ "remote: Counting objects: 0% \n",
540540+ },
541541+ {
542542+ "unterminated apc dropped",
543543+ "keep\x1b_bk;t=1no-bel-here",
544544+ "keep",
545545+ },
546546+ {
547547+ // Underscores unrelated to bk;t= must survive untouched.
548548+ "underscore not metadata",
549549+ "foo_bar baz_qux\n",
550550+ "foo_bar baz_qux\n",
551551+ },
552552+ {
553553+ // CSI colour codes are stripped down to plain text.
554554+ "strips csi colour",
555555+ "\x1b[90m# comment\x1b[0m\n",
556556+ "# comment\n",
557557+ },
558558+ {
559559+ // Clear-to-EOL embedded mid-line is removed too.
560560+ "strips csi clear-to-eol",
561561+ "remote: Counting objects: 1% (2/144) \x1b[K\n",
562562+ "remote: Counting objects: 1% (2/144) \n",
563563+ },
564564+ {
565565+ // OSC title-set sequences (terminated by BEL) drop.
566566+ "strips osc bel terminated",
567567+ "before\x1b]0;window title\x07after\n",
568568+ "beforeafter\n",
569569+ },
570570+ {
571571+ // OSC terminated by ST (ESC '\') also drops.
572572+ "strips osc st terminated",
573573+ "before\x1b]0;title\x1b\\after\n",
574574+ "beforeafter\n",
575575+ },
576576+ {
577577+ // Two-byte escapes like ESC '=' (DECKPAM) drop.
578578+ "strips two byte escape",
579579+ "a\x1b=b\n",
580580+ "ab\n",
581581+ },
582582+ }
583583+ for _, c := range cases {
584584+ t.Run(c.name, func(t *testing.T) {
585585+ got := stripTerminal(c.in)
586586+ if got != c.want {
587587+ t.Fatalf("got %q; want %q", got, c.want)
588588+ }
589589+ })
590590+ }
591591+}