Stitch any CI into Tangled
151
fork

Configure Feed

Select the types of activity you want to include in your feed.

buildkite: strip terminal sequences from the logs

+181
+97
provider_buildkite.go
··· 434 434 body = "" 435 435 } 436 436 437 + // Buildkite injects per-line timestamp metadata as 438 + // ANSI APC sequences (ESC "_" "bk;t=<ms>" BEL) and 439 + // some renderers downstream don't recognise the APC 440 + // envelope, leaking the inner "_bk;t=…" payload into 441 + // the displayed text. Strip them here so consumers 442 + // only ever see the actual log content. 443 + body = stripTerminal(body) 444 + 437 445 for _, line := range strings.Split(strings.TrimRight(body, "\n"), "\n") { 438 446 if line == "" { 439 447 // Skip the leading empty entry that ··· 651 659 return strings.TrimPrefix(ref, prefix) 652 660 } 653 661 return ref 662 + } 663 + 664 + // stripTerminal removes ANSI/ECMA-48 escape sequences from a log 665 + // payload, leaving only the displayable text. We need this because 666 + // Buildkite ships its plain-text log API with the agent's full 667 + // terminal output — per-line timestamp APC envelopes 668 + // (`ESC _ "bk;t=<unix-ms>" BEL`), CSI colour codes, clear-to-EOL 669 + // (`ESC [ K`), OSC title sets, etc. — and our consumers are not 670 + // terminal emulators; they render the bytes verbatim. 671 + // 672 + // We recognise the standard escape families described by ECMA-48: 673 + // 674 + // - CSI: ESC '[' parameters intermediates final 675 + // - OSC/DCS/APC/SOS/PM: ESC (']'|'P'|'_'|'X'|'^') … (BEL | ESC '\') 676 + // - everything else: ESC <single byte> 677 + // 678 + // As a safety belt we also strip the bare "_bk;t=<digits>" residue 679 + // that appears when an upstream processor has stripped the ESC/BEL 680 + // framing without understanding the APC envelope inside it. 681 + // 682 + // We should use libghostty for obvious reasons. 683 + func stripTerminal(s string) string { 684 + if !strings.ContainsAny(s, "\x1b_") { 685 + return s 686 + } 687 + var b strings.Builder 688 + b.Grow(len(s)) 689 + for i := 0; i < len(s); { 690 + if s[i] == 0x1b && i+1 < len(s) { 691 + switch s[i+1] { 692 + case '[': 693 + // CSI: parameter bytes 0x30-0x3F, then 694 + // intermediate bytes 0x20-0x2F, then a 695 + // single final byte 0x40-0x7E. Anything 696 + // that doesn't conform we drop minimally 697 + // (just the ESC) so we don't swallow 698 + // legitimate text. 699 + j := i + 2 700 + for j < len(s) && s[j] >= 0x30 && s[j] <= 0x3F { 701 + j++ 702 + } 703 + for j < len(s) && s[j] >= 0x20 && s[j] <= 0x2F { 704 + j++ 705 + } 706 + if j < len(s) && s[j] >= 0x40 && s[j] <= 0x7E { 707 + i = j + 1 708 + continue 709 + } 710 + i += 2 711 + continue 712 + case ']', 'P', '_', 'X', '^': 713 + // OSC/DCS/APC/SOS/PM: terminated by BEL or 714 + // ST (ESC '\'). Drop the entire envelope. 715 + j := i + 2 716 + for j < len(s) { 717 + if s[j] == 0x07 { 718 + j++ 719 + break 720 + } 721 + if s[j] == 0x1b && j+1 < len(s) && s[j+1] == '\\' { 722 + j += 2 723 + break 724 + } 725 + j++ 726 + } 727 + i = j 728 + continue 729 + default: 730 + // Two-byte escape (RIS, DECSC, charset 731 + // selection, …). Drop both bytes. 732 + i += 2 733 + continue 734 + } 735 + } 736 + // Bare residue: "_bk;t=<digits>" with no ESC/BEL framing. 737 + if s[i] == '_' && strings.HasPrefix(s[i:], "_bk;t=") { 738 + j := i + len("_bk;t=") 739 + for j < len(s) && s[j] >= '0' && s[j] <= '9' { 740 + j++ 741 + } 742 + if j > i+len("_bk;t=") { 743 + i = j 744 + continue 745 + } 746 + } 747 + b.WriteByte(s[i]) 748 + i++ 749 + } 750 + return b.String() 654 751 } 655 752 656 753 // sendLine pushes one LogLine into out, returning false if ctx
+84
provider_buildkite_test.go
··· 505 505 }) 506 506 } 507 507 } 508 + 509 + // TestStripTerminal exercises both shapes of Buildkite's inline 510 + // timestamp metadata: the well-formed APC envelope ESC _ … BEL, and 511 + // the bare "_bk;t=<digits>" residue we sometimes see when an upstream 512 + // processor strips control bytes without understanding the envelope. 513 + func TestStripTerminal(t *testing.T) { 514 + cases := []struct { 515 + name string 516 + in string 517 + want string 518 + }{ 519 + {"empty", "", ""}, 520 + {"no metadata", "hello world\n", "hello world\n"}, 521 + { 522 + "framed apc", 523 + "\x1b_bk;t=1777680702553\x07~~~ Running agent environment hook\n", 524 + "~~~ Running agent environment hook\n", 525 + }, 526 + { 527 + "multiple framed apc per line", 528 + "\x1b_bk;t=1\x07a\x1b_bk;t=2\x07b\n", 529 + "ab\n", 530 + }, 531 + { 532 + "bare residue", 533 + "_bk;t=1777680702553~~~ Running agent environment hook\n", 534 + "~~~ Running agent environment hook\n", 535 + }, 536 + { 537 + "bare residue mid-line", 538 + "remote: Counting objects: 0% _bk;t=1777680705851\n", 539 + "remote: Counting objects: 0% \n", 540 + }, 541 + { 542 + "unterminated apc dropped", 543 + "keep\x1b_bk;t=1no-bel-here", 544 + "keep", 545 + }, 546 + { 547 + // Underscores unrelated to bk;t= must survive untouched. 548 + "underscore not metadata", 549 + "foo_bar baz_qux\n", 550 + "foo_bar baz_qux\n", 551 + }, 552 + { 553 + // CSI colour codes are stripped down to plain text. 554 + "strips csi colour", 555 + "\x1b[90m# comment\x1b[0m\n", 556 + "# comment\n", 557 + }, 558 + { 559 + // Clear-to-EOL embedded mid-line is removed too. 560 + "strips csi clear-to-eol", 561 + "remote: Counting objects: 1% (2/144) \x1b[K\n", 562 + "remote: Counting objects: 1% (2/144) \n", 563 + }, 564 + { 565 + // OSC title-set sequences (terminated by BEL) drop. 566 + "strips osc bel terminated", 567 + "before\x1b]0;window title\x07after\n", 568 + "beforeafter\n", 569 + }, 570 + { 571 + // OSC terminated by ST (ESC '\') also drops. 572 + "strips osc st terminated", 573 + "before\x1b]0;title\x1b\\after\n", 574 + "beforeafter\n", 575 + }, 576 + { 577 + // Two-byte escapes like ESC '=' (DECKPAM) drop. 578 + "strips two byte escape", 579 + "a\x1b=b\n", 580 + "ab\n", 581 + }, 582 + } 583 + for _, c := range cases { 584 + t.Run(c.name, func(t *testing.T) { 585 + got := stripTerminal(c.in) 586 + if got != c.want { 587 + t.Fatalf("got %q; want %q", got, c.want) 588 + } 589 + }) 590 + } 591 + }