diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ac22f95..4df073e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -117,6 +117,37 @@ jobs: token: ${{ env.CODECOV_TOKEN }} fail_ci_if_error: false + # Go fuzzes one target per invocation, and its corpus of "interesting" inputs is + # a machine-local cache by design — not something to commit. The seed corpus + # (f.Add, plus any crasher checked in) already runs as an ordinary test in the + # `test` job; this job is the part that explores inputs nobody has seen yet. + # + # Time-boxed rather than exhaustive: the point is steady pressure on every PR. + # On a failure the toolchain writes the reproducer under testdata/fuzz/, so it + # is uploaded — committing that file turns the crash into a permanent test. + fuzz: + name: fuzz + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Fuzz + run: make fuzz FUZZTIME=60s + + - name: Upload crash reproducers + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-crashers + path: "**/testdata/fuzz/**" + if-no-files-found: ignore + # Lint results are platform-independent, so this runs once rather than three # times. It also exercises a Makefile target, which keeps CI and the local # `make check` entry point from drifting apart. diff --git a/Makefile b/Makefile index 74a0e4e..7578977 100644 --- a/Makefile +++ b/Makefile @@ -54,9 +54,23 @@ fmt: ## Format the tree fmt-check: ## Fail if anything is unformatted @out=$$(gofmt -l .); if [ -n "$$out" ]; then echo "unformatted:"; echo "$$out"; exit 1; fi +# Go fuzzes one target per invocation, so discover them rather than naming one: +# a hardcoded target silently passes once the package it names moves or is not +# written yet. Exits non-zero if it finds nothing, for the same reason. +FUZZTIME ?= 30s + .PHONY: fuzz -fuzz: ## Short fuzz run over the JSONC parser (DCL-10) - go test -run=NONE -fuzz=FuzzParse -fuzztime=60s ./pkg/jsonc +fuzz: ## Bounded fuzz run over every Fuzz target (FUZZTIME=30s) + @set -e; \ + found=0; \ + for pkg in $$(go list ./...); do \ + for fn in $$(go test -list='Fuzz.*' $$pkg 2>/dev/null | grep '^Fuzz' || true); do \ + found=1; \ + echo "==> $$pkg $$fn ($(FUZZTIME))"; \ + go test -run='^$$' -fuzz="^$$fn$$" -fuzztime=$(FUZZTIME) $$pkg; \ + done; \ + done; \ + if [ $$found -eq 0 ]; then echo "no fuzz targets found"; exit 1; fi .PHONY: check check: fmt-check vet lint test ## Everything CI runs diff --git a/pkg/position/bench_test.go b/pkg/position/bench_test.go new file mode 100644 index 0000000..99511f4 --- /dev/null +++ b/pkg/position/bench_test.go @@ -0,0 +1,43 @@ +package position_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/lonhutt/dcx/pkg/position" +) + +// These exist to answer one question if it is ever raised: is the column scan +// worth optimising? Measured first, optimised only if the numbers say so. +// +// For scale: the worst line in the 86 KB fixture is 186 characters, so the scan +// from line start to offset is bounded by that regardless of file size. + +func BenchmarkNew(b *testing.B) { + src := loadFixture(b) + b.ReportAllocs() + b.SetBytes(int64(len(src))) + for b.Loop() { + _ = position.New(src) + } +} + +func BenchmarkUTF16(b *testing.B) { + src := loadFixture(b) + ix := position.New(src) + off := position.Offset(len(src) / 2) + b.ReportAllocs() + for b.Loop() { + _, _ = ix.UTF16(off) + } +} + +func loadFixture(tb testing.TB) []byte { + tb.Helper() + src, err := os.ReadFile(filepath.Join("testdata", "large-commented.jsonc")) + if err != nil { + tb.Fatalf("read fixture: %v", err) + } + return src +} diff --git a/pkg/position/fuzz_test.go b/pkg/position/fuzz_test.go new file mode 100644 index 0000000..f2c2d63 --- /dev/null +++ b/pkg/position/fuzz_test.go @@ -0,0 +1,73 @@ +package position_test + +import ( + "testing" + + "github.com/lonhutt/dcx/pkg/position" +) + +// FuzzLineIndex asserts the two properties that must hold for arbitrary bytes: +// nothing panics, and every result stays in bounds and round trips. +// +// Malformed UTF-8 is a normal input for a linter, not an exotic one. The oracle +// and the implementation agree on it as long as both advance one byte per +// invalid byte, which is what utf8.DecodeRune does when it returns RuneError. +func FuzzLineIndex(f *testing.F) { + for _, seed := range []string{ + "", + "{}", + "a\n", + "a\r\nb\rc", + mixed, + "😀\n中\né", + "a\xffb", // invalid byte + "\xe4", // truncated multi-byte sequence + "\ufeff{}\n", // BOM + } { + f.Add([]byte(seed)) + } + + f.Fuzz(func(t *testing.T, src []byte) { + ix := position.New(src) + orc := newOracle(src) + + if n := ix.LineCount(); n < 1 { + t.Fatalf("LineCount() = %d, must be at least 1 even for empty input", n) + } + if got, want := ix.LineCount(), orc.lineCount(); got != want { + t.Fatalf("LineCount() = %d, oracle says %d", got, want) + } + + for _, off := range runeBoundaries(src) { + // Checked against the oracle as well as through its own inverse: a + // defect shared by UTF8 and OffsetUTF8 satisfies the round trip while + // still producing the wrong column. + wantLine, wantCol := orc.utf8At(off) + l, c := ix.UTF8(position.Offset(off)) + if l != wantLine || c != wantCol { + t.Fatalf("UTF8(%d) = (%d,%d), oracle says (%d,%d)", off, l, c, wantLine, wantCol) + } + if back := ix.OffsetUTF8(l, c); back != position.Offset(off) { + t.Fatalf("utf8 round trip: offset %d -> (%d,%d) -> %d", off, l, c, back) + } + + line, char := ix.UTF16(position.Offset(off)) + if line < 0 || line >= ix.LineCount() { + t.Fatalf("UTF16(%d) line = %d, out of range [0,%d)", off, line, ix.LineCount()) + } + if char < 0 { + t.Fatalf("UTF16(%d) char = %d, negative", off, char) + } + + if back := ix.OffsetUTF16(line, char); back != position.Offset(off) { + t.Fatalf("round trip: offset %d -> (%d,%d) -> %d", off, line, char, back) + } + + // Agreement with the reference implementation must hold here too, + // not just on the curated corpus. + if wl, wc := orc.utf16At(off); wl != line || wc != char { + t.Fatalf("UTF16(%d) = (%d,%d), oracle says (%d,%d)", off, line, char, wl, wc) + } + } + }) +} diff --git a/pkg/position/oracle_test.go b/pkg/position/oracle_test.go new file mode 100644 index 0000000..4fe3491 --- /dev/null +++ b/pkg/position/oracle_test.go @@ -0,0 +1,106 @@ +package position_test + +import ( + "unicode/utf16" + "unicode/utf8" +) + +// This file is the reference implementation the real one is tested against. +// +// The per-offset arithmetic here is deliberately the slowest, most obviously +// correct code that could work: it re-encodes the line prefix through []rune and +// utf16.Encode on every call, because that is the *definition* of an LSP column +// rather than an optimisation of it. Nothing in that path should ever be made +// clever. Its only job is to be so simple that when it disagrees with +// pkg/position, the bug is in pkg/position. +// +// The one concession is that line splitting is hoisted into newOracle instead of +// being redone per call. That is not cleverness, it is the difference between a +// 0.3s test and a 30s one on the 86 KB fixture: splitting is O(n) and was being +// run once per offset, making the whole check O(n^2). + +type oracle struct { + src []byte + starts []int // byte offset at which each line begins +} + +// newOracle splits src into lines, treating "\n", "\r\n" and a lone "\r" as +// terminators — which is what VS Code and other LSP clients do. If this +// disagrees with the real implementation, every position after the first stray +// carriage return is silently wrong. +func newOracle(src []byte) *oracle { + starts := []int{0} + for i := 0; i < len(src); { + switch src[i] { + case '\n': + i++ + starts = append(starts, i) + case '\r': + i++ + if i < len(src) && src[i] == '\n' { + i++ + } + starts = append(starts, i) + default: + i++ + } + } + // A source ending in a terminator has a final, empty line whose start offset + // equals len(src). That line is addressable and must not be dropped. + return &oracle{src: src, starts: starts} +} + +func (o *oracle) lineCount() int { return len(o.starts) } + +// line returns the 0-based line containing off. +func (o *oracle) line(off int) int { + line := 0 + for i, s := range o.starts { + if s > off { + break + } + line = i + } + return line +} + +// utf8At returns the 0-based line and the column measured in runes. +func (o *oracle) utf8At(off int) (line, col int) { + line = o.line(off) + return line, utf8.RuneCount(o.src[o.starts[line]:off]) +} + +// utf16At returns the 0-based line and the column measured in UTF-16 code units, +// which is what the Language Server Protocol means by Position.character. +// +// Invalid UTF-8 becomes one U+FFFD per bad byte here, which is also how +// utf8.DecodeRune advances, so the two agree even on malformed input. +func (o *oracle) utf16At(off int) (line, char int) { + line = o.line(off) + return line, len(utf16.Encode([]rune(string(o.src[o.starts[line]:off])))) +} + +// runeBoundaries returns every offset in [0, len(src)] that names a position. +// +// Offsets inside a multi-byte rune have no meaningful column and cannot round +// trip, so tests must not assert on them. The interior of a "\r\n" pair is +// excluded for the same reason: the terminator is one unit of line structure, +// and LSP columns are measured over a line's visible text, which stops before +// it. An offset between the two bytes therefore names no column at all. +func runeBoundaries(src []byte) []int { + offs := make([]int, 0, len(src)+1) + for i := 0; i < len(src); { + if src[i] == '\n' && i > 0 && src[i-1] == '\r' { + i++ + continue + } + offs = append(offs, i) + // Advance by what DecodeRune consumes, not by one byte with a RuneStart + // filter. The two agree on valid UTF-8, but inside a truncated sequence + // DecodeRune yields a one-byte RuneError per continuation byte, and those + // offsets are reachable positions the filter would skip. + _, size := utf8.DecodeRune(src[i:]) + i += size + } + return append(offs, len(src)) // EOF is always a valid position +} diff --git a/pkg/position/position.go b/pkg/position/position.go new file mode 100644 index 0000000..14c7cda --- /dev/null +++ b/pkg/position/position.go @@ -0,0 +1,214 @@ +package position + +import ( + "slices" + "unicode/utf16" + "unicode/utf8" +) + +// Offset is a byte offset into a document. It is the single coordinate the rest +// of dcx passes around; line/column pairs exist only at the edges, where a +// terminal or an LSP client demands them. +type Offset int + +// Range is a half-open byte span, [Start, End). Start equal to End is an empty +// range that still points somewhere meaningful — the caret position for an +// insertion, for instance. +type Range struct { + Start, End Offset +} + +// LineIndex maps byte offsets to line/column pairs for one document. Build it +// once per document with New. +// +// It retains src rather than copying it, so src must not be mutated while the +// index is in use. +type LineIndex struct { + src []byte + lineStarts []int // byte offset at which each line begins +} + +// New indexes src by line, treating "\n", "\r\n" and a lone "\r" as terminators, +// which is what VS Code and other LSP clients do. +// +// A document ending in a terminator has a final, empty line whose start offset +// equals len(src). The line count is therefore always at least one, and EOF is +// always an addressable position — every node's end offset can land there. +func New(src []byte) *LineIndex { + lineStarts := []int{0} + for i := 0; i < len(src); { + switch src[i] { + case '\n': + i++ + lineStarts = append(lineStarts, i) + case '\r': + i++ + if i < len(src) && src[i] == '\n' { + i++ // consume the \n of a \r\n pair + } + lineStarts = append(lineStarts, i) + default: + i++ + } + } + return &LineIndex{src: src, lineStarts: lineStarts} +} + +// clampLine constrains line to one that exists. Design §4.2 invariant 1 forbids +// panicking below cmd/, and an editor naming a line a previous edit deleted is +// routine traffic rather than a programming error. +func (ix *LineIndex) clampLine(line int) int { + if line < 0 { + return 0 + } + if line >= len(ix.lineStarts) { + return len(ix.lineStarts) - 1 + } + return line +} + +// clampOffset constrains off to [0, len(src)]. The upper bound is inclusive +// because EOF is an addressable position. +func (ix *LineIndex) clampOffset(off Offset) int { + if off < 0 { + return 0 + } + if int(off) > len(ix.src) { + return len(ix.src) + } + return int(off) +} + +// LineCount returns the number of lines, which is at least 1 even for an empty +// document. +func (ix *LineIndex) LineCount() int { return len(ix.lineStarts) } + +// LineStart returns the byte offset at which line begins. +// +// line is clamped to a line that exists, so an out-of-range value returns the +// first or last line's start rather than panicking. +func (ix *LineIndex) LineStart(line int) Offset { + return Offset(ix.lineStarts[ix.clampLine(line)]) +} + +// Line returns the 0-based line containing off, in O(log lines). +// +// BinarySearch reports the index at which off would be inserted. A hit means off +// is exactly a line start; a miss means off falls inside the preceding line, +// hence i-1. off is clamped to [0, len(src)] first, so a stale offset from +// before an edit resolves to the first or last line instead of panicking. +func (ix *LineIndex) Line(off Offset) int { + i, found := slices.BinarySearch(ix.lineStarts, ix.clampOffset(off)) + if found { + return i + } + return i - 1 +} + +// LineRange returns the byte range of line, excluding its terminator, so a +// diagnostic drawn over a whole line does not wrap into the next one. Callers +// that want the terminator included use LineStart(line+1) as the end. +// +// line is clamped to a line that exists. +func (ix *LineIndex) LineRange(line int) Range { + line = ix.clampLine(line) + start := ix.lineStarts[line] + + // The last line runs to EOF; every other line runs to the next line's start, + // which sits just past that line's terminator. + end := len(ix.src) + if line+1 < len(ix.lineStarts) { + end = ix.lineStarts[line+1] + } + + // Back up over the terminator. These are two sequential ifs rather than an + // else-if because "\r\n" has to shed both bytes. The end > start guard keeps + // an empty line from backing up past its own start into an inverted range. + if end > start && ix.src[end-1] == '\n' { + end-- + } + if end > start && ix.src[end-1] == '\r' { + end-- + } + + return Range{Start: Offset(start), End: Offset(end)} +} + +// UTF8 returns the 0-based line containing off and the column measured in runes, +// which is what a terminal renderer needs to place a caret under the right +// character. +// +// Invalid UTF-8 counts one rune per bad byte, matching how utf8.DecodeRune +// advances, so columns stay consistent on malformed input. +// +// An offset past the line's visible end — the interior of a "\r\n" pair is the +// only way to reach one — clamps to that end, so the column always converts back +// through OffsetUTF8 to the offset this returned it for. +func (ix *LineIndex) UTF8(off Offset) (line, col int) { + o := ix.clampOffset(off) + line = ix.Line(Offset(o)) + o = min(o, int(ix.LineRange(line).End)) + return line, utf8.RuneCount(ix.src[ix.lineStarts[line]:o]) +} + +// UTF16 returns the 0-based line containing off and the column measured in +// UTF-16 code units, which is what the Language Server Protocol means by +// Position.character. +// +// The difference from UTF8 is not cosmetic: an astral-plane character such as an +// emoji is one rune but two code units, so the two columns diverge after it. +// +// As with UTF8, an offset inside a "\r\n" pair clamps to the line's visible end, +// keeping the column within what OffsetUTF16 will accept. +// +// Ranging over string(...) here does not allocate. The compiler special-cases a +// []byte-to-string conversion used directly as a range expression; assigning it +// to a variable first would cost a copy. +func (ix *LineIndex) UTF16(off Offset) (line, char int) { + o := ix.clampOffset(off) + line = ix.Line(Offset(o)) + o = min(o, int(ix.LineRange(line).End)) + for _, r := range string(ix.src[ix.lineStarts[line]:o]) { + char += utf16.RuneLen(r) + } + return line, char +} + +// OffsetUTF8 converts a (line, rune column) pair back into a byte offset. It is +// the inverse of UTF8, completing the arrow Design §5.2 specifies in both +// directions. +// +// As with OffsetUTF16, a col past the end of the line clamps to that line's +// visible end, so the returned offset always belongs to the line asked for. +func (ix *LineIndex) OffsetUTF8(line, col int) Offset { + lr := ix.LineRange(line) + i, end := int(lr.Start), int(lr.End) + for col > 0 && i < end { + _, size := utf8.DecodeRune(ix.src[i:end]) + i += size + col-- + } + return Offset(i) +} + +// OffsetUTF16 converts an LSP position back into a byte offset. It is the +// inverse of UTF16, and the half DCL-55 depends on: didChange delivers UTF-16 +// ranges that must become offsets before text can be spliced. +// +// A char landing inside a surrogate pair resolves to the offset just past the +// character containing it. +// +// A char past the end of the line clamps to that line's visible end, per the LSP +// rule that an overlong character defaults back to the line length. The scan is +// bounded by LineRange rather than by the document, so the returned offset always +// belongs to the line that was asked for. +func (ix *LineIndex) OffsetUTF16(line, char int) Offset { + lr := ix.LineRange(line) + i, end := int(lr.Start), int(lr.End) + for char > 0 && i < end { + r, size := utf8.DecodeRune(ix.src[i:end]) + i += size + char -= utf16.RuneLen(r) + } + return Offset(i) +} diff --git a/pkg/position/position_test.go b/pkg/position/position_test.go new file mode 100644 index 0000000..3e1a12e --- /dev/null +++ b/pkg/position/position_test.go @@ -0,0 +1,405 @@ +package position_test + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/lonhutt/dcx/pkg/position" +) + +// mixed exercises all four width classes in one line. +// +// byte: 0 1 2 3 4 5 6 7 8 9 10 11 12 +// a ' ' é(2) ' ' 中(3) ' ' 😀(4) +// +// The last row of the table below is the one that matters: at end of line the +// rune column is 7 but the UTF-16 column is 8, because the emoji is a surrogate +// pair. Any implementation that conflates the two passes every other row. +const mixed = "a é 中 😀" + +func TestColumnsAtRuneBoundaries(t *testing.T) { + ix := position.New([]byte(mixed)) + + for _, tc := range []struct { + off int + wantRune int + wantUTF16 int + what string + }{ + {0, 0, 0, "start"}, + {1, 1, 1, "after 'a'"}, + {2, 2, 2, "start of é"}, + {4, 3, 3, "after é (2 bytes, 1 unit)"}, + {5, 4, 4, "start of 中"}, + {8, 5, 5, "after 中 (3 bytes, 1 unit)"}, + {9, 6, 6, "start of 😀"}, + {13, 7, 8, "after 😀 — 4 bytes, 1 rune, TWO utf-16 units"}, + } { + gotLine, gotCol := ix.UTF8(position.Offset(tc.off)) + if gotLine != 0 || gotCol != tc.wantRune { + t.Errorf("UTF8(%d) [%s] = (%d,%d), want (0,%d)", + tc.off, tc.what, gotLine, gotCol, tc.wantRune) + } + + gotLine, gotChar := ix.UTF16(position.Offset(tc.off)) + if gotLine != 0 || gotChar != tc.wantUTF16 { + t.Errorf("UTF16(%d) [%s] = (%d,%d), want (0,%d)", + tc.off, tc.what, gotLine, gotChar, tc.wantUTF16) + } + } +} + +func TestLineStructure(t *testing.T) { + for _, tc := range []struct { + name string + src string + wantLines int + }{ + {"empty is one line", "", 1}, + {"no trailing newline", "a\nb", 2}, + {"trailing newline adds an empty final line", "a\n", 2}, + {"two trailing newlines", "a\n\n", 3}, + {"crlf", "a\r\nb", 2}, + {"lone cr is a terminator", "a\rb", 2}, + {"mixed terminators", "a\r\nb\nc\rd", 4}, + {"only a newline", "\n", 2}, + } { + t.Run(tc.name, func(t *testing.T) { + ix := position.New([]byte(tc.src)) + if got := ix.LineCount(); got != tc.wantLines { + t.Errorf("LineCount(%q) = %d, want %d", tc.src, got, tc.wantLines) + } + // EOF must always be addressable: every node's end offset can land there. + if line, _ := ix.UTF16(position.Offset(len(tc.src))); line != tc.wantLines-1 { + t.Errorf("UTF16(EOF) line = %d, want last line %d", line, tc.wantLines-1) + } + }) + } +} + +// TestAgainstOracle is the acceptance criterion: for every rune boundary in the +// document, the real implementation must agree with the reference one, and the +// UTF-16 position must convert back to the byte offset it came from. +func TestAgainstOracle(t *testing.T) { + for _, tc := range []struct{ name, src string }{ + {"empty", ""}, + {"ascii", "{\n \"name\": \"x\"\n}\n"}, + {"latin1", "é\néé\n"}, + {"cjk", "中文\n中\n"}, + {"astral", "😀\n😀😀\n"}, + {"mixed", mixed}, + {"crlf", "a\r\nb\r\n"}, + {"lone cr", "a\rb\r"}, + {"emoji then ascii on same line", "😀abc\n"}, + {"invalid utf8", "a\xffb\n\xe4\n"}, + {"bom", "\ufeff{}\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + checkAgainstOracle(t, []byte(tc.src)) + }) + } +} + +// TestAgainstOracleLargeFixture runs the same comparison over a real 86 KB +// devcontainer.json with 1,885 non-ASCII characters and 9 astral-plane emoji +// spread across 1,558 lines. Hand-written cases prove the arithmetic; this +// proves it survives reality. +func TestAgainstOracleLargeFixture(t *testing.T) { + src, err := os.ReadFile(filepath.Join("testdata", "large-commented.jsonc")) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + checkAgainstOracle(t, src) +} + +func checkAgainstOracle(t *testing.T, src []byte) { + t.Helper() + ix := position.New(src) + orc := newOracle(src) + + // The line tables must agree before any per-offset comparison means much: a + // differing line count means the two are indexing different documents, and + // every column below would be checked against the wrong line. + if got, want := ix.LineCount(), orc.lineCount(); got != want { + t.Fatalf("LineCount() = %d, oracle says %d", got, want) + } + + for _, off := range runeBoundaries(src) { + wantLine, wantCol := orc.utf8At(off) + gotLine, gotCol := ix.UTF8(position.Offset(off)) + if gotLine != wantLine || gotCol != wantCol { + t.Fatalf("UTF8(%d) = (%d,%d), oracle says (%d,%d)\n%s", + off, gotLine, gotCol, wantLine, wantCol, context(src, off)) + } + + // The UTF-8 direction round trips too: Design §5.2 specifies the arrow + // both ways, for a terminal renderer that has a column and needs a span. + if back := ix.OffsetUTF8(gotLine, gotCol); back != position.Offset(off) { + t.Fatalf("OffsetUTF8(%d,%d) = %d, want %d (round trip)\n%s", + gotLine, gotCol, back, off, context(src, off)) + } + + wantLine, wantChar := orc.utf16At(off) + gotLine, gotChar := ix.UTF16(position.Offset(off)) + if gotLine != wantLine || gotChar != wantChar { + t.Fatalf("UTF16(%d) = (%d,%d), oracle says (%d,%d)\n%s", + off, gotLine, gotChar, wantLine, wantChar, context(src, off)) + } + + // Round trip. This is the half that DCL-55 depends on: didChange sends + // UTF-16 ranges that must become byte offsets before text can be spliced. + if back := ix.OffsetUTF16(gotLine, gotChar); back != position.Offset(off) { + t.Fatalf("OffsetUTF16(%d,%d) = %d, want %d (round trip)\n%s", + gotLine, gotChar, back, off, context(src, off)) + } + } +} + +// context renders the neighbourhood of a failing offset, because "want 41 got 42" +// is not enough to debug a column bug. +func context(src []byte, off int) string { + lo, hi := off-20, off+20 + if lo < 0 { + lo = 0 + } + if hi > len(src) { + hi = len(src) + } + var b strings.Builder + b.WriteString(" context: ") + b.WriteString(strconv.Quote(string(src[lo:off]))) + b.WriteString(" ") + b.WriteString(strconv.Quote(string(src[off:hi]))) + return b.String() +} + +// TestLineRange pins the terminator semantics: a line's Range covers its visible +// text and stops before the terminator, so a diagnostic drawn over a whole line +// does not wrap the selection into the next one. Callers that want the +// terminator included ask for LineStart(line+1) instead. +// +// The cases that matter are CRLF (the terminator is two bytes, not one) and the +// empty lines, where backing up over a terminator must not run past the line +// start and produce an inverted range. +func TestLineRange(t *testing.T) { + for _, tc := range []struct { + name string + src string + line int + want string // the text the returned range must cover + }{ + {"lf: first line", "a\nb", 0, "a"}, + {"lf: last line has no terminator", "a\nb", 1, "b"}, + {"crlf backs up two bytes", "a\r\nb", 0, "a"}, + {"crlf: second line", "a\r\nb", 1, "b"}, + {"lone cr is a terminator", "a\rb", 0, "a"}, + {"empty document is one empty line", "", 0, ""}, + {"trailing lf leaves an empty final line", "a\n", 1, ""}, + {"empty line between terminators", "a\n\nb", 1, ""}, + {"only a terminator", "\n", 0, ""}, + {"multi-byte runes: offsets are bytes", "中文\n", 0, "中文"}, + {"astral", "😀\n", 0, "😀"}, + } { + t.Run(tc.name, func(t *testing.T) { + ix := position.New([]byte(tc.src)) + r := ix.LineRange(tc.line) + if r.Start > r.End { + t.Fatalf("LineRange(%d) = %+v: start is past end", tc.line, r) + } + if got := tc.src[r.Start:r.End]; got != tc.want { + t.Errorf("LineRange(%d) = %+v covering %q, want %q", + tc.line, r, got, tc.want) + } + }) + } +} + +// TestOffsetUTF16Clamps pins the LSP rule that a character past the end of a +// line defaults back to the line length, where "length" is the visible text and +// excludes the terminator. +// +// The scan used to be bounded by the end of the *document*, so an overlong +// character walked on into the following lines and returned an offset belonging +// to a line the caller never asked about. Clients are permitted to send exactly +// that, and incremental didChange (Design §9.2) turns the result straight into a +// splice point. +func TestOffsetUTF16Clamps(t *testing.T) { + for _, tc := range []struct { + name string + src string + line, char int + want position.Offset + }{ + {"char past end of line stops at visible end", "a\nbbbb\n", 0, 3, 1}, + {"char far past end", "a\nbbbb\n", 0, 99, 1}, + {"char exactly at visible end", "a\nbbbb\n", 0, 1, 1}, + {"the terminator itself is not addressable", "a\nbbbb\n", 0, 2, 1}, + {"middle line clamps to its own end", "a\nbbbb\n", 1, 99, 6}, + {"last line without a terminator", "a\nb", 1, 99, 3}, + {"empty final line", "a\n", 1, 5, 2}, + {"crlf sheds both bytes", "a\r\nb", 0, 5, 1}, + {"astral: one rune is two units", "😀x\n", 0, 2, 4}, + {"astral: clamp past end", "😀x\n", 0, 99, 5}, + } { + t.Run(tc.name, func(t *testing.T) { + ix := position.New([]byte(tc.src)) + if got := ix.OffsetUTF16(tc.line, tc.char); got != tc.want { + t.Errorf("OffsetUTF16(%d, %d) on %q = %d, want %d", + tc.line, tc.char, tc.src, got, tc.want) + } + }) + } +} + +// TestOffsetUTF8Clamps is TestOffsetUTF16Clamps' counterpart: a rune column past +// the end of a line resolves to that line's visible end, never into the next +// line's text. +func TestOffsetUTF8Clamps(t *testing.T) { + for _, tc := range []struct { + name string + src string + line, col int + want position.Offset + }{ + {"col past end of line stops at visible end", "a\nbbbb\n", 0, 3, 1}, + {"col far past end", "a\nbbbb\n", 0, 99, 1}, + {"col exactly at visible end", "a\nbbbb\n", 0, 1, 1}, + {"middle line clamps to its own end", "a\nbbbb\n", 1, 99, 6}, + {"last line without a terminator", "a\nb", 1, 99, 3}, + {"empty final line", "a\n", 1, 5, 2}, + {"crlf sheds both bytes", "a\r\nb", 0, 5, 1}, + {"astral: one rune is one column", "😀x\n", 0, 1, 4}, + {"multi-byte: columns are runes, offsets are bytes", "中文\n", 0, 1, 3}, + {"negative col is the line start", "a\nbbbb\n", 1, -3, 2}, + } { + t.Run(tc.name, func(t *testing.T) { + ix := position.New([]byte(tc.src)) + if got := ix.OffsetUTF8(tc.line, tc.col); got != tc.want { + t.Errorf("OffsetUTF8(%d, %d) on %q = %d, want %d", + tc.line, tc.col, tc.src, got, tc.want) + } + }) + } +} + +// TestOutOfRangeLinesClamp pins Design §4.2 invariant 1: nothing below cmd/ +// panics. A didChange naming a line that a previous edit deleted is normal +// editor traffic, not malformed input. +// +// The policy is to clamp — line into [0, LineCount()) — matching what the two +// Offset* methods already do with an overlong column. +func TestOutOfRangeLinesClamp(t *testing.T) { + const src = "a\nbb\n" // 3 lines: "a", "bb", "" + ix := position.New([]byte(src)) + + for _, tc := range []struct { + name string + call func() any + want any + }{ + {"LineStart negative", func() any { return ix.LineStart(-5) }, position.Offset(0)}, + {"LineStart past end", func() any { return ix.LineStart(99) }, position.Offset(5)}, + {"LineRange negative", func() any { return ix.LineRange(-5) }, position.Range{Start: 0, End: 1}}, + {"LineRange past end", func() any { return ix.LineRange(99) }, position.Range{Start: 5, End: 5}}, + {"Line negative offset", func() any { return ix.Line(-5) }, 0}, + {"Line offset past EOF", func() any { return ix.Line(99) }, 2}, + {"OffsetUTF16 negative line", func() any { return ix.OffsetUTF16(-5, 0) }, position.Offset(0)}, + {"OffsetUTF16 line past end", func() any { return ix.OffsetUTF16(99, 0) }, position.Offset(5)}, + {"OffsetUTF8 negative line", func() any { return ix.OffsetUTF8(-5, 0) }, position.Offset(0)}, + {"OffsetUTF8 line past end", func() any { return ix.OffsetUTF8(99, 0) }, position.Offset(5)}, + } { + t.Run(tc.name, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("panicked: %v", r) + } + }() + if got := tc.call(); got != tc.want { + t.Errorf("= %v, want %v", got, tc.want) + } + }) + } +} + +// TestOutOfRangeOffsetsClamp is the same invariant for the offset-taking +// conversions. A byte offset from a stale parse can outlive the edit that +// shortened the document. +func TestOutOfRangeOffsetsClamp(t *testing.T) { + const src = "a\nbb\n" + ix := position.New([]byte(src)) + + for _, tc := range []struct { + name string + off position.Offset + wantLine, wantCol int + }{ + {"negative offset is the document start", -5, 0, 0}, + {"offset past EOF is the last line", 99, 2, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("panicked: %v", r) + } + }() + if l, c := ix.UTF8(tc.off); l != tc.wantLine || c != tc.wantCol { + t.Errorf("UTF8(%d) = (%d,%d), want (%d,%d)", tc.off, l, c, tc.wantLine, tc.wantCol) + } + if l, c := ix.UTF16(tc.off); l != tc.wantLine || c != tc.wantCol { + t.Errorf("UTF16(%d) = (%d,%d), want (%d,%d)", tc.off, l, c, tc.wantLine, tc.wantCol) + } + }) + } +} + +// TestCRLFInteriorClamps covers the one offset class runeBoundaries deliberately +// does not enumerate: a position between the two bytes of a "\r\n" pair. +// +// It is a rune boundary, so a caller can hand it to UTF8 or UTF16, but it lies +// past the line's visible end. It therefore clamps there, and the column it +// yields converts back to that clamped offset — rather than to a column its own +// inverse would reject, which is what it did before. +// +// The oracle cannot check this: it measures columns over a raw line prefix and +// has no notion of clamping, which is exactly why it is simple enough to trust. +func TestCRLFInteriorClamps(t *testing.T) { + for _, tc := range []struct { + name string + src string + off int + wantLine int + wantCol, wantChar int // rune column, UTF-16 column + wantBack position.Offset + }{ + {"between cr and lf", "a\r\nb", 2, 0, 1, 1, 1}, + {"crlf at the start of the document", "\r\nb", 1, 0, 0, 0, 0}, + {"crlf on a later line", "a\r\nbb\r\n", 6, 1, 2, 2, 5}, + {"multi-byte rune before the crlf", "中\r\n", 4, 0, 1, 1, 3}, + {"astral before the crlf: the two columns differ", "😀\r\n", 5, 0, 1, 2, 4}, + } { + t.Run(tc.name, func(t *testing.T) { + ix := position.New([]byte(tc.src)) + + line, col := ix.UTF8(position.Offset(tc.off)) + if line != tc.wantLine || col != tc.wantCol { + t.Errorf("UTF8(%d) on %q = (%d,%d), want (%d,%d)", + tc.off, tc.src, line, col, tc.wantLine, tc.wantCol) + } + if back := ix.OffsetUTF8(line, col); back != tc.wantBack { + t.Errorf("OffsetUTF8(%d,%d) = %d, want %d", line, col, back, tc.wantBack) + } + + line, char := ix.UTF16(position.Offset(tc.off)) + if line != tc.wantLine || char != tc.wantChar { + t.Errorf("UTF16(%d) on %q = (%d,%d), want (%d,%d)", + tc.off, tc.src, line, char, tc.wantLine, tc.wantChar) + } + if back := ix.OffsetUTF16(line, char); back != tc.wantBack { + t.Errorf("OffsetUTF16(%d,%d) = %d, want %d", line, char, back, tc.wantBack) + } + }) + } +} diff --git a/pkg/position/testdata/README.md b/pkg/position/testdata/README.md new file mode 100644 index 0000000..ebfc02f --- /dev/null +++ b/pkg/position/testdata/README.md @@ -0,0 +1,12 @@ +# position test fixtures + +`large-commented.jsonc` — a real `devcontainer.json` from +`Ilenburg1993/chatgpt-docker-puppeteer`, retrieved 2026-09-04. + +It is here because it is the widest gap between bytes and characters found in a +survey of public dev container configs: 86,007 bytes but 82,799 characters, with +1,885 non-ASCII characters and 9 astral-plane emoji across 1,558 lines. Every one +of those emoji is 4 UTF-8 bytes and 2 UTF-16 code units, which is exactly the case +a naive column implementation gets wrong. + +Median real-world devcontainer.json is about 1.7 KB; this is the p100 tail. diff --git a/pkg/position/testdata/large-commented.jsonc b/pkg/position/testdata/large-commented.jsonc new file mode 100644 index 0000000..33c54ec --- /dev/null +++ b/pkg/position/testdata/large-commented.jsonc @@ -0,0 +1,1558 @@ +// ============================================================================ +// DevContainer — Ambiente de Desenvolvimento Controlado - v5.9.1 +// Projeto: chatgpt-docker-puppeteer +// +// Propósito: +// Definir um ambiente de desenvolvimento determinístico, auditável e +// arquiteturalmente neutro, no qual: +// +// • A infraestrutura fornece CAPACIDADE (runtime, rede, volumes) +// • O sistema decide COMPORTAMENTO (topologia, browser, orquestração) +// • O editor (VS Code) não interfere em planos de controle internos +// +// Princípios: +// • Deny-by-default para portas +// • Puppeteer opera exclusivamente em modo "connect" +// • Chrome é externo ao container (host ou serviço dedicado) +// • Nenhuma decisão de topologia é hardcoded na infraestrutura +// • Debug e observabilidade são opt-in e não intrusivos +// • Documentação robusta (NUNCA reduzir) +// +// Escopo: +// • Ambiente DEV (não produção) +// • Suporte a Puppeteer, Node.js, PM2, Docker CLI (host socket) +// • Estabilidade e previsibilidade > conveniência automática +// +// Nota Arquitetural Importante: +// Portas de controle (ex.: Chrome Proxy / Debug) não são auto-expostas por +// inferência. Quando declaradas, são forwardadas explicitamente e com política +// de UX silenciosa/ignore. O acesso operacional continua decidido pelo runtime. +// +// CHANGELOG v5.9.1 (2026-08-18): +// 🌐 NETWORK OBSERVABILITY + CONTROL-PLANE PATH SELF-HEALING +// ✅ ALINHADO: Dockerfile v1.5.1; post-create v1.2.3; post-start v3.0.3. +// ✅ ALINHADO: local-dns-cache v1.8.1; network-control-plane-state v1.1.1. +// ✅ CORRIGIDO: caminho canônico do Network Control Plane sem o segmento `network/` espúrio. +// ✅ CORRIGIDO: versões/labels ativos do DevContainer e da imagem, antes divergentes do Dockerfile canônico. +// ✅ ENDURECIDO: hooks recuperam override runtime stale/ilegível usando o script canônico existente. +// ✅ MANTIDO: DNS default-on fail-safe; proxy local opt-in; benchmarks longos fora do boot. +// +// CHANGELOG v5.9.0 (2026-05-20): +// 🌐 DEFAULT-ON DNS + POST-START 3.0.2 + CONTROL PLANE STATE SYNC +// ✅ ALINHADO: Dockerfile v1.4.2 +// ✅ ALINHADO: package.json v1.1.3 +// ✅ ALINHADO: Makefile v4.3.0 +// ✅ ALINHADO: post-create v1.2.2 +// ✅ ALINHADO: post-start v3.0.2 +// ✅ ALINHADO: post-attach v5.9.0 +// ✅ ALINHADO: healthcheck v3.0.0 +// ✅ ALINHADO: sync-local-auth v2.0.0 +// ✅ ALINHADO: network-control-plane-state v1.1.0 +// ✅ ALINHADO: github-api-route-fix v1.9.1 +// ✅ ALINHADO: local-dns-cache v1.8.0 +// ✅ ALINHADO: local-copilot-proxy v1.3.1 +// ✅ ALINHADO: github-copilot-network-manager v1.6.1 +// ✅ ALINHADO: copilot-route-advisor v1.1.0 +// ✅ ALINHADO: endpoints.github-copilot.tsv v1.2.0 +// ✅ CORRIGIDO: post-start agora usa action=start para o manager, gerando +// snapshot runtime bounded em vez de apenas recomendação stale. +// ✅ CORRIGIDO: DNS local default-on com prova forte antes de governar resolv.conf. +// ✅ ADICIONADO: knobs v1.8.0 para Docker embedded DNS split-horizon, warmup, +// ranking sem benchmark no boot, stale detection e probe forte. +// ✅ ADICIONADO: network-control-plane-state como agregador passivo pós-start. +// ✅ ADICIONADO: separação explícita entre summary runtime e action.summary. +// ✅ MANTIDO: proxy local desligado por padrão; nenhum HTTP(S)_PROXY global. +// ✅ MANTIDO: benchmark prolongado manual/opt-in; boot permanece bounded. +// ✅ ATUALIZADO: labels runtime para devcontainer.version=5.9.0. +// +// CHANGELOG v5.8.0 (2026-05-19): +// 🧭 CANONICAL PATH REALIGNMENT + CONTROL PLANE SYNC +// ✅ ALINHADO: Dockerfile v1.4.2 +// ✅ ALINHADO: post-create v1.1.0 +// ✅ ALINHADO: post-start v2.8.1 +// ✅ ALINHADO: post-attach v5.7.1 +// ✅ ALINHADO: github-api-route-fix v1.8.6 +// ✅ ALINHADO: local-dns-cache v1.5.3 +// ✅ ALINHADO: local-copilot-proxy v1.2.3 +// ✅ ALINHADO: github-copilot-network-manager v1.5.3 +// ✅ ALINHADO: copilot-route-advisor v1.0.1 +// ✅ ALINHADO: nss-gatekeeper v2.1.2 +// ✅ CORRIGIDO: endpoint registry aponta para .devcontainer/scripts/network/ +// ✅ CORRIGIDO: aliases DEVCONTAINER_COPILOT_ENDPOINT_REGISTRY(_FILE) +// ✅ CORRIGIDO: remoção do override DEVCONTAINER_COPILOT_PROBE_ENDPOINTS +// para permitir que o registry seja a fonte de verdade. +// ✅ CORRIGIDO: knobs do local-copilot-proxy para nomes realmente consumidos +// pelo script v1.2.3. +// ✅ ADICIONADO: metadados DEVCONTAINER_VERSION / CONTROL_PLANE_GENERATION. +// ✅ ADICIONADO: limites explícitos de consumo do endpoint registry. +// ✅ MANTIDO: benchmark prolongado manual/opt-in; boot permanece recommend/quick. +// ✅ MANTIDO: proxy local desligado por padrão; sem HTTPS_PROXY/HTTP_PROXY global. +// ✅ MANTIDO: DNS cache local em auto/ranked com fail-closed. +// ✅ ATUALIZADO: labels runtime para devcontainer.version=5.8.0. +// +// // CHANGELOG v5.7.0 (2026-05-17): +// 🧭 NETWORK CONTROL PLANE + SAFE BENCHMARK INTEGRATION +// ✅ ALINHADO: Dockerfile v1.4.2 +// ✅ ALINHADO: package.json v1.1.0 +// ✅ ALINHADO: Makefile v4.2.0 +// ✅ ALINHADO: post-create v1.0.4 +// ✅ ALINHADO: post-start v2.8.0 +// ✅ ALINHADO: post-attach v5.7.0 +// ✅ ALINHADO: github-api-route-fix v1.8.4 +// ✅ ALINHADO: local-copilot-proxy v1.2.2 +// ✅ ALINHADO: github-copilot-network-manager v1.5.0 +// ✅ ADICIONADO: defaults formais para benchmark prolongado manual/opt-in +// ✅ ADICIONADO: recommendation artifacts e policy bridge sem aplicação automática +// ✅ ADICIONADO: endpoint registry oficial para GitHub/Copilot +// ✅ ADICIONADO: superfície Copilot ampliada (origin-tracker + telemetry) +// ✅ MANTIDO: proxy local desligado por padrão; sem HTTPS_PROXY/HTTP_PROXY global +// ✅ MANTIDO: benchmark longo fora do boot; post-start apenas quick/recommend +// ✅ ATUALIZADO: build.args.VERSION e labels para Dockerfile 1.4.2 / devcontainer 5.7.0 +// +// CHANGELOG v5.6.0 (2026-05-16): +// 🌐 DNS CACHE PROMOTION + NETWORK HARDENING +// ✅ ALINHADO: Dockerfile v1.4.2 +// ✅ ALINHADO: post-start v2.8.0 +// ✅ ALINHADO: github-api-route-fix v1.8.4 +// ✅ ALINHADO: local-dns-cache v1.5.1 +// ✅ ALINHADO: local-copilot-proxy v1.2.2 +// ✅ ALINHADO: github-copilot-network-manager v1.5.0 +// ✅ PROMOVIDO: DNS cache local habilitado por padrão em modo auto/ranked +// ✅ ADICIONADO: hardening de dnsmasq start/repair/ownership/port-check +// ✅ ADICIONADO: thresholds e locks explícitos para GitHub/Copilot manager +// ✅ ADICIONADO: verificação estrita de IP aplicado no route-fix +// ✅ MANTIDO: proxy local desligado por padrão até teste controlado posterior +// ✅ CORRIGIDO: DEVCONTAINER_MAKE_TIMEOUT=30, validado após PM2 warmup +// +// CHANGELOG v5.5.0 (2026-05-16): +// 🌐 NETWORK ARCHITECTURE SYNC (GITHUB/COPILOT) +// ✅ ALINHADO: Dockerfile v1.4.2 +// ✅ ALINHADO: post-start v2.8.0 +// ✅ ALINHADO: github-api-route-fix v1.8.4 +// ✅ ALINHADO: local-dns-cache v1.2.0 +// ✅ ALINHADO: local-copilot-proxy v1.2.2 +// ✅ ALINHADO: github-copilot-network-manager v1.5.0 +// ✅ ADICIONADO: Copilot Network Manager habilitado por padrão +// ✅ ADICIONADO: DNS cache local e proxy local como infraestrutura opt-in +// ✅ CORRIGIDO: CODEX_HOME aponta para ${containerWorkspaceFolder}/.codex +// ✅ CORRIGIDO: lifecycle hooks quotados e chamados por bash explicitamente +// ✅ CORRIGIDO: build.args.VERSION e labels para 1.4.0 / devcontainer 5.5.0 +// +// CHANGELOG v5.4.0 (2026-05-15): +// 🔧 DOCKERFILE/NSS SYNC (CANONICAL ALIGNMENT) +// ✅ ALINHADO: Dockerfile.canonical.v1.3 +// ✅ ALINHADO: nss-gatekeeper canonical v2.0.0 +// ✅ ALINHADO: post-start v2.5.0 +// ✅ ALINHADO: post-attach v5.4.0 +// ✅ CORRIGIDO: build.args.VERSION 1.0 → 1.3 +// ✅ CORRIGIDO: LD_PRELOAD absoluto/canônico em containerEnv + remoteEnv +// ✅ CORRIGIDO: portsAttributes wildcard → otherPortsAttributes oficial +// ✅ ADICIONADO: GitHub.copilot explicitamente junto de GitHub.copilot-chat +// ✅ ADICIONADO: overrideCommand=false para honrar CMD/ENTRYPOINT da imagem +// ✅ ADICIONADO: labels runtime devcontainer.version e image.version +// +// CHANGELOG v5.3 (2026-02-03): +// 🔧 SSH FORWARDING MIGRATION (BREAKING CHANGE - FIX CRÍTICO) +// ❌ REMOVIDO: Mount manual de SSH socket (causava erro fatal) +// ❌ REMOVIDO: SSH_AUTH_SOCK hardcoded em remoteEnv +// ❌ REMOVIDO: DEVCONTAINER_SECRET_SURFACE_SSH (redundante) +// ❌ REMOVIDO: DEVCONTAINER_SSH_AGENT_ALLOWED (containerEnv) +// ✅ ADICIONADO: VS Code native SSH forwarding (automático) +// ✅ ADICIONADO: Documentação completa da migração +// ✅ RESOLVIDO: Container agora inicia COM ou SEM SSH agent +// +// REFERÊNCIAS: +// • .devcontainer/MIGRATION_SSH_V5.3.md (documentação completa) +// • .devcontainer/TROUBLESHOOTING_SSH.md (guia de debug) +// • DEVCONTAINER_BUILD_ANALYSIS.md (análise técnica) +// +// CHANGELOG v5.2 (2026-02-02): +// ✅ Sincronização de versão (3.4 → 5.2) +// ✅ Features consolidadas (removidas duplicações) +// ✅ Extensions agrupadas por categoria +// ✅ Scrollback otimizado (20000 → 10000) +// ✅ Documentação de segurança melhorada (--group-add=docker) +// ✅ remoteEnv consolidado (removidas duplicações) +// ============================================================================ +{ + // ============================================================ + // BUILD CONFIGURATION (v5.9.1 — Dockerfile v1.5.1 sync) + // ============================================================ + "build": { + "args": { + "BUILD_DATE": "${localEnv:BUILD_DATE}", + "BUILD_ENV": "dev", + // 🔐 SINCRONIZAÇÃO DOCKER + "DOCKER_GID": "${localEnv:DOCKER_GID}", + "IMAGE_NAME": "chatgpt-docker-puppeteer", + "IMAGE_VENDOR": "Yuri", + "PROJECT_NAME": "${localWorkspaceFolderBasename}", + // --- O APERTO DE MÃO (Identidade) --- + // REMOTE_USER: Sincroniza remoteUser com Dockerfile (torna imagem dinâmica) + // Dockerfile usa: ENV USER_NAME=${REMOTE_USER} + // NOTA: ${containerUser} NÃO existe como variável built-in do DevContainers. + // Usar valor literal que corresponde ao remoteUser (linha ~998) + "REMOTE_USER": "node", + "VCS_REF": "${localEnv:GIT_COMMIT}", + // --- METADATA & VERSIONING --- + "VERSION": "1.5.1", + // --- NETWORK CONTROL PLANE DEFAULTS --- + // Usados apenas como defaults de imagem; benchmark longo permanece manual. + "NETWORK_BENCHMARK_DURATION_SECONDS": "${localEnv:NETWORK_BENCHMARK_DURATION_SECONDS:600}", + "NETWORK_BENCHMARK_INTERVAL_SECONDS": "${localEnv:NETWORK_BENCHMARK_INTERVAL_SECONDS:10}", + "NETWORK_RECOMMENDATION_TTL_SECONDS": "${localEnv:NETWORK_RECOMMENDATION_TTL_SECONDS:86400}" + }, + "context": "..", + "dockerfile": "Dockerfile" + }, + // ============================================================ + // CONTAINER ENVIRONMENT VARIABLES (v5.9.1 — NSS/Dockerfile/network sync) + // ============================================================ + "containerEnv": { + // canonical shell contract mirrored from /etc/profile.d/00-runtime.sh + // ensures non‑login terminals inherit the same semantic hints as + // interactive logins. purely informational, used by helpers and tests. + "SHELL_CANONICAL": "bash", + "CANONICAL_SHELL": "bash", + "INSTRUMENTAL_SHELLS": "pwsh", + "DEVCONTAINER_MAKE_TIMEOUT": "30", + + "APP_ENV": "dev", + "APP_NAME": "chatgpt-docker-puppeteer", + "APP_ROLE": "agent", + "DEVCONTAINER_VERSION": "5.9.1", + "DEVCONTAINER_CONTROL_PLANE_GENERATION": "network-control-plane-v5.9.1", + "DEVCONTAINER_CANONICAL_ENDPOINT_REGISTRY": "${containerWorkspaceFolder}/.devcontainer/scripts/network/endpoints.github-copilot.tsv", + "DEVCONTAINER_POST_START_SCRIPT_VERSION_EXPECTED": "3.0.3", + "DEVCONTAINER_POST_ATTACH_SCRIPT_VERSION_EXPECTED": "5.9.1", + "DEVCONTAINER_HEALTHCHECK_SCRIPT_VERSION_EXPECTED": "3.0.1", + "DEVCONTAINER_NETWORK_CONTROL_PLANE_SCRIPT_VERSION_EXPECTED": "1.1.1", + "DEVCONTAINER_SYNC_LOCAL_AUTH_SCRIPT_VERSION_EXPECTED": "2.0.0", + // CHOKIDAR_USEPOLLING e CHOKIDAR_INTERVAL removidos em v5.4.0: + // O workspace é montado como ext4 (inotify nativo funciona). + // Vite já configura polling explicitamente em vite.config.js (watch.usePolling: true). + // Os watchers do servidor usam fs.watch() nativo — não dependem de chokidar. + // Referência: DEVCONTAINER_ARCHITECTURE.md [DEC-001] + "DEBUG": "puppeteer:*,agent:*", + "DEVCONTAINER_DOCKER_ENABLED": "true", + "DOCKER_BUILDKIT": "1", + "DOCKER_HOST_ACCESS": "true", + "DOCKER_SECURITY_LEVEL": "host-root-equivalent", + // Canonical NSS artifact directory. + // Used by profile.d + nss-gatekeeper; safe as plain metadata in containerEnv. + "DEVCONTAINER_NSS_DIR": "/tmp/devcontainer-nss", + // ========================================================= + // NETWORK RESILIENCE — GitHub/Copilot route manager + // --------------------------------------------------------- + // Smart route selector for api.github.com, delegated by + // post-start.sh to .devcontainer/scripts/network/github-api-route-fix.sh. + // It repairs ISP/DNS edge failures without changing Windows/host network. + // ========================================================= + "DEVCONTAINER_ENABLE_GITHUB_API_ROUTE_FIX": "true", + "DEVCONTAINER_GITHUB_API_HOST": "api.github.com", + "DEVCONTAINER_GITHUB_API_FUNCTIONALITY_PROFILE": "copilot", + "DEVCONTAINER_GITHUB_API_BENCHMARK_DURATION_SECONDS": "600", + "DEVCONTAINER_GITHUB_API_BENCHMARK_INTERVAL_SECONDS": "10", + "DEVCONTAINER_GITHUB_API_BENCHMARK_MAX_SAMPLES": "0", + "DEVCONTAINER_GITHUB_API_BENCHMARK_INCLUDE_CANDIDATES": "true", + "DEVCONTAINER_GITHUB_API_BENCHMARK_UPDATE_CACHE": "true", + "DEVCONTAINER_GITHUB_API_BENCHMARK_RECOMMEND_MIN_SAMPLES": "5", + "DEVCONTAINER_GITHUB_API_BENCHMARK_MAX_FAIL_RATE_PERCENT": "10", + "DEVCONTAINER_GITHUB_API_BENCHMARK_MIN_IMPROVEMENT_PERCENT": "25", + "DEVCONTAINER_GITHUB_API_BENCHMARK_RECOMMENDATION_TTL_SECONDS": "86400", + "DEVCONTAINER_GITHUB_API_ROUTE_PROXY_MODE": "auto", + "DEVCONTAINER_GITHUB_API_MIN_SCORE": "85", + "DEVCONTAINER_GITHUB_API_MAX_CANDIDATES": "16", + "DEVCONTAINER_GITHUB_API_PARALLEL_PROBES": "true", + "DEVCONTAINER_GITHUB_API_ROUTE_CACHE_ENABLED": "true", + "DEVCONTAINER_GITHUB_API_ENABLE_IPV6": "false", + "DEVCONTAINER_GITHUB_API_OPENSSL_PREFLIGHT": "false", + "DEVCONTAINER_GITHUB_API_STRICT_VERIFY_EXPECTED_IP": "true", + "DEVCONTAINER_GITHUB_API_CACHE_LOCK_WAIT_SECONDS": "10", + "DEVCONTAINER_GITHUB_API_HOSTS_LOCK_WAIT_SECONDS": "15", + "DEVCONTAINER_GITHUB_API_ROUTE_CACHE_MAX_ENTRIES": "128", + "DEVCONTAINER_GITHUB_API_ROUTE_CACHE_MAX_AGE_SECONDS": "86400", + "DEVCONTAINER_GITHUB_API_ACTION_UPDATE_RUNTIME_SUMMARY": "false", + "DEVCONTAINER_GITHUB_API_BENCHMARK_UPDATE_RUNTIME_SUMMARY": "false", + "DEVCONTAINER_GITHUB_API_ROUTE_CONNECT_TIMEOUT": "4", + "DEVCONTAINER_GITHUB_API_ROUTE_MAX_TIME": "12", + "DEVCONTAINER_GITHUB_API_VERSION": "2022-11-28", + "DEVCONTAINER_GITHUB_API_ROLLBACK_ON_VERIFY_FAILURE": "true", + "DEVCONTAINER_GITHUB_API_OPTIMIZE_WHEN_CURRENT_OK": "false", + "DEVCONTAINER_GITHUB_API_HYSTERESIS_SCORE_MARGIN": "5000", + "DEVCONTAINER_GITHUB_API_RECENT_FAILURE_HARD_BLOCK_SECONDS": "0", + "DEVCONTAINER_SKIP_GITHUB_API_PROBES_AFTER_ROUTE_FIX": "true", + + // ========================================================= + // NETWORK RESILIENCE — Modular GitHub/Copilot layer (v5.5.0) + // --------------------------------------------------------- + // Camada 1: github-api-route-fix.sh corrige ativamente apenas api.github.com. + // Camada 2: github-copilot-network-manager.sh agrega route-fix + probes. + // Camada 3: local-dns-cache.sh e local-copilot-proxy.sh existem como + // infraestrutura opt-in, não ativada automaticamente. + // + // Política: + // • ativo por padrão: manager + route-fix de api.github.com; + // • opt-in: cache DNS local e proxy HTTP CONNECT local; + // • IPv6 para api.github.com permanece off até haver AAAA real funcional. + // ========================================================= + "DEVCONTAINER_ENABLE_COPILOT_NETWORK_MANAGER": "true", + "DEVCONTAINER_COPILOT_NETWORK_MANAGER_MODE": "active", + "DEVCONTAINER_COPILOT_NETWORK_MANAGER_POST_START_ACTION": "start", + "DEVCONTAINER_COPILOT_TRANSPORT_PROFILE": "auto", + "DEVCONTAINER_POST_START_APPLY_TRANSPORT_RECOMMENDATION": "false", + "DEVCONTAINER_COPILOT_NETWORK_BENCHMARK_DURATION_SECONDS": "600", + "DEVCONTAINER_COPILOT_NETWORK_BENCHMARK_INTERVAL_SECONDS": "10", + "DEVCONTAINER_COPILOT_NETWORK_BENCHMARK_MAX_SAMPLES": "0", + "DEVCONTAINER_COPILOT_NETWORK_RECOMMENDATION_TTL_SECONDS": "86400", + // Endpoint registry canônico: + // • o arquivo vive junto dos scripts de rede em .devcontainer/scripts/network/ + // • ambos os aliases são definidos porque post-start, post-attach, + // github-copilot-network-manager e post-create aceitam nomes diferentes. + // • NÃO definir DEVCONTAINER_COPILOT_PROBE_ENDPOINTS aqui; quando presente, + // ele sobrescreve o registry e impede que a allowlist TSV governe os probes. + "DEVCONTAINER_COPILOT_ENDPOINT_REGISTRY": "${containerWorkspaceFolder}/.devcontainer/scripts/network/endpoints.github-copilot.tsv", + "DEVCONTAINER_COPILOT_ENDPOINT_REGISTRY_FILE": "${containerWorkspaceFolder}/.devcontainer/scripts/network/endpoints.github-copilot.tsv", + "DEVCONTAINER_COPILOT_USE_ENDPOINT_REGISTRY": "true", + "DEVCONTAINER_POST_START_ENDPOINT_REGISTRY_MAX_ROWS": "64", + "DEVCONTAINER_COPILOT_MANAGER_MAX_ENDPOINTS": "64", + "DEVCONTAINER_COPILOT_MANAGER_RUN_API_ROUTE_FIX": "true", + "DEVCONTAINER_COPILOT_MANAGER_FAIL_ON_DEGRADED": "false", + "DEVCONTAINER_COPILOT_MANAGER_SUBSCRIPT_TIMEOUT_SECONDS": "90", + "DEVCONTAINER_COPILOT_WARN_TOTAL_MS": "1500", + "DEVCONTAINER_COPILOT_PROBE_CONNECT_TIMEOUT": "4", + "DEVCONTAINER_COPILOT_PROBE_MAX_TIME": "12", + "DEVCONTAINER_COPILOT_PROBE_IP_FAMILY": "4", + "DEVCONTAINER_COPILOT_PROBE_PROXY_MODE": "auto", + "DEVCONTAINER_COPILOT_PROBE_PARALLEL": "true", + "DEVCONTAINER_COPILOT_NETWORK_LOCK_WAIT_SECONDS": "30", + "DEVCONTAINER_COPILOT_NETWORK_HISTORY_LOCK_WAIT_SECONDS": "10", + "DEVCONTAINER_COPILOT_NETWORK_HISTORY_ENABLED": "true", + "DEVCONTAINER_COPILOT_NETWORK_HISTORY_MAX_LINES": "2000", + "DEVCONTAINER_COPILOT_NETWORK_HISTORY_WINDOW": "40", + "DEVCONTAINER_COPILOT_NETWORK_HISTORY_SLOW_THRESHOLD": "1500", + "DEVCONTAINER_COPILOT_NETWORK_HISTORY_FAIL_THRESHOLD": "3", + "DEVCONTAINER_COPILOT_MANAGER_PARALLEL_PROBES": "true", + "DEVCONTAINER_COPILOT_MANAGER_MARK_UNSTABLE_AS_DEGRADED": "true", + "DEVCONTAINER_COPILOT_MANAGER_FAIL_ON_UNSTABLE": "false", + "DEVCONTAINER_COPILOT_MANAGER_ALLOW_CUSTOM_ENDPOINTS": "false", + "DEVCONTAINER_COPILOT_MANAGER_ALLOW_CUSTOM_GITHUB_API_HOST": "false", + // DEVCONTAINER_COPILOT_PROBE_ENDPOINTS deliberadamente omitido. + // Fallbacks permanecem nos scripts; a fonte primária agora é o TSV canônico. + + // DNS cache local: default-on em modo auto/ranked com local-dns-cache v1.8.0. + // O script só escreve /etc/resolv.conf depois de prova local forte + // (dig/drill/nslookup), preserva search/domain e mantém split-horizon + // para Docker embedded DNS quando 127.0.0.11 existir no runtime. + "DEVCONTAINER_ENABLE_LOCAL_DNS_CACHE": "true", + "DEVCONTAINER_LOCAL_DNS_CACHE_MODE": "auto", + "DEVCONTAINER_LOCAL_DNS_MODE": "auto", + "DEVCONTAINER_LOCAL_DNS_POST_START_ACTION": "start", + "DEVCONTAINER_LOCAL_DNS_CACHE_ACTION": "start", + "DEVCONTAINER_POST_START_DNS_BASELINE_ON_CACHE_OFF": "true", + "DEVCONTAINER_POST_START_DNS_BASELINE_ON_CACHE_FAILURE": "true", + "DEVCONTAINER_LOCAL_DNS_BIND_ADDRESS": "127.0.0.1", + "DEVCONTAINER_LOCAL_DNS_BIND_PORT": "53", + "DEVCONTAINER_LOCAL_DNS_UPSTREAM_SELECTION": "ranked", + "DEVCONTAINER_LOCAL_DNS_UPSTREAMS": "1.1.1.1 1.0.0.1 8.8.8.8 8.8.4.4 9.9.9.9 149.112.112.112", + "DEVCONTAINER_LOCAL_DNS_BENCHMARK_HOSTS": "api.github.com github.com copilot-proxy.githubusercontent.com api.githubcopilot.com", + "DEVCONTAINER_LOCAL_DNS_START_MODE": "auto", + "DEVCONTAINER_LOCAL_DNS_WRITE_RESOLV_CONF": "true", + "DEVCONTAINER_LOCAL_DNS_REPAIR_ON_PROBE_FAILURE": "true", + "DEVCONTAINER_LOCAL_DNS_VALIDATE_CONFIG": "true", + "DEVCONTAINER_LOCAL_DNS_STRICT_PORT_CHECK": "true", + "DEVCONTAINER_LOCAL_DNS_TAKEOVER_STALE_DNSMASQ": "true", + "DEVCONTAINER_LOCAL_DNS_STOP_BY_SOCKET_OWNER": "true", + "DEVCONTAINER_LOCAL_DNS_STOP_WAIT_MS": "2000", + "DEVCONTAINER_LOCAL_DNS_FORWARD_MAX": "150", + "DEVCONTAINER_LOCAL_DNS_CACHE_SIZE": "10000", + "DEVCONTAINER_LOCAL_DNS_MIN_CACHE_TTL": "0", + "DEVCONTAINER_LOCAL_DNS_MAX_CACHE_TTL": "300", + "DEVCONTAINER_LOCAL_DNS_NEG_TTL": "30", + "DEVCONTAINER_LOCAL_DNS_RESOLV_OPTIONS": "timeout:1 attempts:2 rotate", + "DEVCONTAINER_LOCAL_DNS_READ_ETC_HOSTS": "false", + "DEVCONTAINER_LOCAL_DNS_LOG_QUERIES": "false", + "DEVCONTAINER_LOCAL_DNS_ENABLE_IPV6_UPSTREAMS": "false", + "DEVCONTAINER_LOCAL_DNS_ALL_SERVERS": "false", + "DEVCONTAINER_LOCAL_DNS_STRICT_ORDER": "false", + "DEVCONTAINER_LOCAL_DNS_USE_STALE_CACHE": "false", + "DEVCONTAINER_LOCAL_DNS_USE_STALE_CACHE_TTL": "60", + "DEVCONTAINER_LOCAL_DNS_ACTION_UPDATE_RUNTIME_SUMMARY": "false", + "DEVCONTAINER_LOCAL_DNS_REQUIRE_PROVEN_LOCAL_PROBE_FOR_RESOLV_CONF": "true", + "DEVCONTAINER_LOCAL_DNS_ALLOW_PROCESS_ONLY_LOCAL_PROBE": "false", + "DEVCONTAINER_LOCAL_DNS_REPAIR_STALE_PIDFILE": "true", + "DEVCONTAINER_LOCAL_DNS_PRESERVE_RESOLV_SEARCH": "true", + "DEVCONTAINER_LOCAL_DNS_DOCKER_EMBEDDED_MODE": "auto", + "DEVCONTAINER_LOCAL_DNS_DOCKER_EMBEDDED_RESOLVER": "127.0.0.11", + "DEVCONTAINER_LOCAL_DNS_DOCKER_EMBEDDED_ROUTE_UNQUALIFIED": "true", + "DEVCONTAINER_LOCAL_DNS_DOCKER_EMBEDDED_ROUTE_SEARCH_DOMAINS": "true", + "DEVCONTAINER_LOCAL_DNS_REBIND_OK_DOCKER_DOMAINS": "true", + "DEVCONTAINER_LOCAL_DNS_RESTORE_RESOLV_CONF_ON_FAILURE": "true", + "DEVCONTAINER_LOCAL_DNS_RESTORE_RESOLV_CONF_ON_STOP": "true", + "DEVCONTAINER_LOCAL_DNS_WARMUP": "true", + "DEVCONTAINER_LOCAL_DNS_WARMUP_HOSTS": "api.github.com github.com copilot-proxy.githubusercontent.com api.githubcopilot.com default.exp-tas.com origin-tracker.githubusercontent.com", + "DEVCONTAINER_LOCAL_DNS_WARMUP_MAX_HOSTS": "8", + "DEVCONTAINER_LOCAL_DNS_WARMUP_RECORD_TYPES": "A", + "DEVCONTAINER_LOCAL_DNS_REBENCHMARK_ON_START": "false", + "DEVCONTAINER_LOCAL_DNS_FORCE_REBENCHMARK": "false", + "DEVCONTAINER_LOCAL_DNS_RANKING_MAX_AGE_SECONDS": "86400", + "DEVCONTAINER_LOCAL_DNS_REBENCHMARK_MIN_SECONDS": "900", + "DEVCONTAINER_LOCAL_DNS_RANKING_HYSTERESIS_SCORE_MARGIN": "5000", + "DEVCONTAINER_LOCAL_DNS_STATUS_STALE_MAX_SECONDS": "300", + "DEVCONTAINER_LOCAL_DNS_FAST_RETRY": "false", + + // Proxy HTTP CONNECT local: disponível na imagem v1.5.1, mas desligado por padrão. + // Para habilitar de forma explícita: DEVCONTAINER_ENABLE_LOCAL_COPILOT_PROXY=true + // e DEVCONTAINER_COPILOT_PROXY_MODE=local. + "DEVCONTAINER_ENABLE_LOCAL_COPILOT_PROXY": "false", + "DEVCONTAINER_COPILOT_PROXY_MODE": "off", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_MODE": "off", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_HOST": "127.0.0.1", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_PORT": "3128", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_APPLY_PROFILE": "false", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_STATUS_STRICT": "false", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_USE_ENDPOINT_REGISTRY": "true", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_ALLOW_CUSTOM_PROBE_URLS": "false", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_ALLOW_NON_LOOPBACK": "false", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_CONNECT_PORTS": "443", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_REMOVE_PROFILE_ON_STOP": "true", + "DEVCONTAINER_POST_START_SOURCE_LOCAL_PROXY_ENV": "false", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_BENCHMARK_DURATION_SECONDS": "600", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_BENCHMARK_INTERVAL_SECONDS": "10", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_BENCHMARK_MAX_SAMPLES": "0", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_BENCHMARK_MIN_SAMPLES": "5", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_BENCHMARK_MIN_IMPROVEMENT_PERCENT": "25", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_BENCHMARK_MAX_FAIL_RATE_PERCENT": "10", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_COMPARE_START_PROXY": "true", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_COMPARE_KEEP_PROXY": "true", + "DEVCONTAINER_POST_START_OBSERVE_LOCAL_PROXY_STATUS": "true", + + // Evita duplicar probes quando o manager v1.1+ já rodou com sucesso. + "DEVCONTAINER_POST_START_LEGACY_PROBES_AFTER_MANAGER": "false", + "DEVCONTAINER_POST_START_SUBSCRIPT_TIMEOUT_SECONDS": "90", + "DEVCONTAINER_ENABLE_NETWORK_CONTROL_PLANE_STATE": "true", + "DEVCONTAINER_NETWORK_CONTROL_PLANE_POST_START_ACTION": "summary", + "DEVCONTAINER_NETWORK_CONTROL_PLANE_TIMEOUT_SECONDS": "10", + "DEVCONTAINER_NETWORK_CONTROL_PLANE_SCRIPT": "${containerWorkspaceFolder}/.devcontainer/scripts/network-control-plane-state.sh", + "DEVCONTAINER_HEALTHCHECK_MIN_NODE_MAJOR": "24", + // Safe to globalize as long as NSS_WRAPPER_* points at files that always exist. + // This keeps base container processes (including VS Code bootstrap paths) + // on a safe, always-readable NSS baseline before profile hooks refine it. + "DEVCONTAINER_NSS_WRAPPER_LIB": "/usr/local/lib/devcontainer/libnss_wrapper.so", + "LD_PRELOAD": "/usr/local/lib/devcontainer/libnss_wrapper.so", + // Bootstrap fallback must point to stable files. /tmp artifacts are runtime + // only and may not exist yet when VS Code issues early exec/user commands. + // The gatekeeper/profile path can still override these to /tmp later. + "NSS_WRAPPER_PASSWD": "/etc/passwd", + "NSS_WRAPPER_GROUP": "/etc/group", + // Canonical tooling metadata exported by the image for wrappers/checkers. + "JSONC_PARSER_MODULE_PATH": "/usr/local/share/npm-global/lib/node_modules/jsonc-parser", + // ========================================================= + // SSH VARIABLES (v5.3 - CLEANUP) + // --------------------------------------------------------- + // REMOVIDO: "DEVCONTAINER_SSH_AGENT_ALLOWED": "true" + // REMOVIDO: "SSH_AUTH_SOCK": "/ssh-agent" + // + // JUSTIFICATIVA: + // • Variáveis eram usadas com mount manual (removido) + // • VS Code native forwarding não requer essas variáveis + // • Simplificação da configuração + // ========================================================= + // Nota estrutural: + // • `containerEnv` deve complementar a imagem, não duplicar defaults + // já fixados no Dockerfile (ex.: NODE_ENV, LOG_LEVEL, LANG). + // • `FORCE_COLOR` NÃO deve ser global aqui: vários shells exportam + // `NO_COLOR`, e a combinação gera warning no startup do Node. + // • Forçar cor deve ficar em env por processo (PM2, testes, helpers). + // ------------------------------------------------------------ + "NPM_CONFIG_CACHE": "/home/node/.npm", + // ------------------------------------------------------------ + // RUNTIME TUNING (Performance & Noise) + // ------------------------------------------------------------ + // NOTA: NODE_OPTIONS removido - cada configuração de launch + // define seu próprio --max-old-space-size em runtimeArgs + // para evitar duplicação e conflitos com o debugger + "PM2_HOME": "/home/node/.pm2", + // Hint semântico (documental, não funcional) + // • Indica que há uma camada proxy entre container e browser + "PUPPETEER_ARCHITECTURE": "external-browser-via-proxy", + // Timeout de conexão CDP (milissegundos) + // • Protege contra stalls de rede + // • NÃO implica que Chrome deva estar ativo + "PUPPETEER_CONNECT_TIMEOUT": "30000", + // ------------------------------------------------------------ + // PUPPETEER & CHROME — INTEGRAÇÃO EXTERNA (CONTRATO CANÔNICO) + // ------------------------------------------------------------ + // + // MODELO FÍSICO REAL (IMPORTANTE): + // + // • Windows Host + // - Chrome REAL (backend de automação) + // - Expõe Chrome DevTools Protocol (CDP) na porta 9225 + // - Bind: 0.0.0.0 (acessível via host.docker.internal) + // - NUNCA é acessado diretamente pelo Puppeteer + // + // • DevContainer + // - Chrome Proxy Service roda AQUI (gerenciado por PM2) + // - Escuta: localhost:9224 (frontend canônico para Puppeteer) + // - Encaminha: host.docker.internal:9225 (backend para Chrome Windows) + // - Puppeteer conecta SEMPRE em localhost:9224 + // + // CONSEQUÊNCIAS DO CONTRATO: + // + // • Puppeteer conecta EXCLUSIVAMENTE em localhost:9224 + // • Proxy encaminha para host.docker.internal:9225 (Chrome no Windows) + // • Isolamento completo: Puppeteer NÃO conhece o host nem a porta 9225 + // • Chrome externo é FUNDAMENTAL para operações LLM via Puppeteer + // • A ausência de Chrome durante build/attach é ESTADO VÁLIDO + // • Chrome será iniciado sob demanda quando operações LLM forem acionadas + // • Nenhum componente de infraestrutura inicia Chrome automaticamente + // + // ------------------------------------------------------------ + "PUPPETEER_MODE": "connect", + // Puppeteer NUNCA deve baixar Chromium automaticamente + // • Chrome externo é primário + // • Chromium local é fallback técnico (imagem-level) + "PUPPETEER_SKIP_DOWNLOAD": "true", + "PUPPETEER_SKIP_CHROMIUM_DOWNLOAD": "true", + "PUPPETEER_EXECUTABLE_PATH": "/usr/bin/chromium", + // ------------------------------------------------------------ + // PUPPETEER — ENDPOINT CANÔNICO (CONTRATO DE FRONTEIRA) + // ------------------------------------------------------------ + // + // TOPOLOGIA FÍSICA REAL: + // ------------------------------------------------------------ + // + // ┌─────────────────────────────────────┐ + // │ DevContainer (Docker) │ + // │ │ + // │ ┌──────────────────────────────┐ │ + // │ │ Puppeteer / Node.js │ │ + // │ │ (conecta localhost:9224) │ │ + // │ └────────────┬─────────────────┘ │ + // │ │ │ + // │ ┌────────────▼─────────────────┐ │ + // │ │ Chrome Proxy Service (PM2) │ │ + // │ │ (bind 0.0.0.0:9224) │ │ + // │ │ • Reescreve Host: headers │ │ + // │ │ • Reescreve WebSocket URLs │ │ + // │ └────────────┬─────────────────┘ │ + // │ │ host.docker.internal│ + // └───────────────┼─────────────────────┘ + // │ + // ┌───────────────▼─────────────────────┐ + // │ Windows Host │ + // │ │ + // │ ┌──────────────────────────────┐ │ + // │ │ Chrome (9225, bind 0.0.0.0) │ │ + // │ │ --remote-debugging-port=9225 │ │ + // │ └──────────────────────────────┘ │ + // │ │ + // └─────────────────────────────────────┘ + // + // VISÃO DO PUPPETEER: + // ------------------------------------------------------------ + // • Conecta SEMPRE em localhost:9224 (proxy no mesmo container) + // • NÃO conhece a porta 9225 + // • NÃO conhece o Windows Host + // • NÃO sabe que há um proxy intermediário + // + // VISÃO DO CHROME PROXY: + // ------------------------------------------------------------ + // • Escuta em 0.0.0.0:9224 (acessível dentro do container) + // • Encaminha para host.docker.internal:9225 (Chrome no Windows) + // • Reescreve URLs para tornar conexão transparente + // + // CONTRATO (INVIOLÁVEL): + // ------------------------------------------------------------ + // • Puppeteer → localhost:9224 (mesma máquina) + // • Proxy → host.docker.internal:9225 (máquina remota) + // • Violação quebra isolamento arquitetural + // ------------------------------------------------------------ + "PUPPETEER_WS_ENDPOINT": "http://localhost:9224", + // ============================================================ + // LOCALE & DOCKER ENGINE + // ============================================================ + // Variáveis de instrumentação (estabilidade de telemetria do servidor) + "VSCODE_INSTRUMENTATION": "true", + "VSCODE_SERVER_EXPECTED": "true", + "VSCODE_SERVER_ROLE": "instrumentation", + // ============================================================ + // LSP LOCAL (legado preservado, sempre desligado por padrão) + // ============================================================ + // O editor usa o TSServer/LSP nativo do TypeScript 7. O daemon MCP local + // só pode ser habilitado explicitamente em um processo isolado. + "LSP_ENABLED": "false", + "LSP_MUTATIONS_ENABLED": "false", + // Timeout por operação LSP (ms). O daemon usa este valor como fallback; + // cada chamada pode sobrescrever via options.timeoutMs. + "LSP_TOOL_TIMEOUT_MS": "15000", + // Número máximo de resultados retornados por operações de busca (references, + // workspace_symbols, diagnostics, completion). Reduzir melhora latência + // em projetos grandes; aumentar melhora completude de busca. + "LSP_MAX_RESULTS": "200" + // ============================================================ + // FILE WATCHING & VS CODE INTERNALS + // ============================================================ + // WATCHPACK_POLLING removido em v5.4.0: + // Projeto usa Vite (não webpack). Variável não tem efeito detectável no projeto. + // Referência: DEVCONTAINER_ARCHITECTURE.md [DEC-002] + }, + // ============================================================ + // VS CODE CUSTOMIZATIONS + // ============================================================ + "customizations": { + "vscode": { + "extensions": [ + "TypeScriptTeam.native-preview", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "ms-azuretools.vscode-containers", + "ms-vscode.makefile-tools", + "timonwong.shellcheck", + "redhat.vscode-yaml", + "EditorConfig.EditorConfig", + "Vue.volar", + "github.vscode-github-actions", + "DavidAnson.vscode-markdownlint" + ], + "settings": { + // TypeScript 7.0.x is GA. VS Code 1.134 still ships the legacy Node/tsserver.js client, so the external + // official LSP client remains required for now under its historical Marketplace ID `TypeScriptTeam.native-preview`. + // Its bundled server is stable TS7 (`tsc --lsp`). Remove the external client once the same client is bundled by VS Code. + // Toggle this flag to false only for a deliberate fallback to the built-in Node/tsserver.js service. + "js/ts.experimental.useTsgo": true, + // O cliente TS7 nativo é Go: esta é a chave efetiva de GOMEMLIMIT. O antigo `js/ts.tsserver.maxMemory` + // configura apenas o fallback Node/tsserver e não limita `tsc --lsp`. + "js/ts.server.goMemLimit": "1024MiB", + // O cliente LSP externo atual (ID histórico `native-preview`) tem trace LSP verboso por default; desabilitamos para não reter tráfego no + // Extension Host nem pagar serialização/logging em toda interação JS/TS. + "js/ts.trace.server": "off", + // In DevContainer, Codex must run in-container instead of being redirected to host-side WSL. + "chatgpt.runCodexInWindowsSubsystemForLinux": false, + "debug.javascript.autoAttachFilter": "onlyWithFlag", + "editor.bracketPairColorization.enabled": true, + //"editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit", + "source.organizeImports": "explicit" + }, + // ======================================================== + // EDITOR & FORMATTING (controle humano explícito) + // ======================================================== + "editor.formatOnSave": true, + "editor.guides.indentation": true, + "editor.minimap.enabled": true, + "editor.minimap.maxColumn": 80, + "editor.minimap.renderCharacters": false, + "editor.renderWhitespace": "boundary", + "editor.smoothScrolling": true, + // ======================================================== + // EDITOR UX (Acessibilidade do Código) + // ======================================================== + "editor.stickyScroll.enabled": true, // Facilita navegar em classes longas do KERNEL + "eslint.alwaysShowStatus": true, + //"[javascript]": { + // "editor.defaultFormatter": "esbenp.prettier-vscode" + //}, + // ======================================================== + // ESLINT (fonte de verdade para qualidade) + // ======================================================== + "eslint.validate": ["javascript", "javascriptreact"], + "eslint.workingDirectories": [ + { + "mode": "auto" + } + ], + "files.associations": { + "*.env*": "dotenv", + "Makefile": "makefile" + }, + "files.autoSave": "onFocusChange", + "files.insertFinalNewline": true, + "files.trimTrailingWhitespace": true, + // ======================================================== + // FILE SYSTEM & SEARCH (A Blindagem) + // ======================================================== + "files.watcherExclude": { + "**/.cache/**": true, // NOVO: Protege contra cache do Puppeteer/NPM + "**/.claude/**": true, // NOVO: Evita indexar estados pesados da IA + "**/.git/objects/**": true, + "**/.git/refs/**": true, + "**/.git/logs/**": true, + "**/backups/**": true, // v5.4.0: exclui backups do watcher + "**/artifacts/**": true, // v5.4.0: arquivos gerados + "**/monitoring/**": true, + "**/dist/**": true, + "**/logs/**": true, + "**/node_modules/**": true, + "**/node-history/**": true, // NOVO: O histórico é para o shell, não para o editor + "**/profile/**": true, + "**/respostas/**": true, + "**/tmp/**": true + }, + // ======================================================== + // EXTENSION HOST — ESTABILIDADE DE MEMÓRIA (v5.4.0) + // O extension host com 55+ extensões pode consumir 1.7+ GB. + // Reduzir auto-save frequency e desabilitar código desnecessário + // alivia o GC do extension host e reduz chances de crash/restart. + // ======================================================== + // Reduz frequência de auto-fetch Git (causa wake-ups do extension host) + "git.autofetch": false, // v5.4.0: desativado (era true) — causa I/O periódico + "git.autofetchPeriod": 1800, // v5.4.0: se reativado, 30 min mínimo + "git.confirmSync": false, + "git.decorations.enabled": true, + "git.enableSmartCommit": true, + "github.copilot.editor.enableAutoCompletions": true, + "github.copilot.editor.iterativeFixing": true, + // ======================================================== + // GITHUB COPILOT — CONFIGURAÇÕES OTIMIZADAS + // ======================================================== + "github.copilot.enable": { + "*": true, + "jsonc": true, + "markdown": true, + "plaintext": true, + "yaml": true + }, + // ======================================================== + // JAVASCRIPT / NODE.JS (infra + backend) + // ======================================================== + "javascript.preferences.importModuleSpecifier": "relative", + "javascript.updateImportsOnFileMove.enabled": "always", + "jest.autoRun": "off", + "jest.runMode": "on-demand", + "json.validate.enable": true, + // ======================================================== + // MARKDOWN & DOCUMENTATION + // ======================================================== + "markdown.preview.breaks": true, + "markdown.preview.scrollPreviewWithEditor": true, + // ======================================================== + // REFERENCES & NAVIGATION + // ======================================================== + "references.preferredLocation": "peek", + "remote.SSH.enableAgentForwarding": true, + "remote.SSH.loglevel": "info", + "remote.SSH.useLocalServer": true, + // ======================================================== + // EXTENSÕES — ESTABILIDADE DE SESSÃO + // Previne atualizações automáticas de extensões durante sessões ativas. + // Atualizações mid-session podem causar incompatibilidade de API proposals + // (ex.: chatParticipantPrivate no Copilot Chat) e reinício do extension host. + // Para atualizar extensões, faça manualmente após encerrar a sessão. + // ======================================================== + "extensions.autoUpdate": false, + "extensions.autoCheckUpdates": false, + "search.exclude": { + "**/.cache": true, + "**/.claude": true, + "**/logs": true, + "**/node_modules": true, + "**/profile": true, + "**/respostas": true + }, + // ======================================================== + // WORKSPACE TRUST & SECURITY + // ======================================================== + "security.workspace.trust.enabled": true, + "shellcheck.enable": true, + // ======================================================== + // TELEMETRY & NOISE REDUCTION + // ======================================================== + "telemetry.telemetryLevel": "off", + "terminal.integrated.copyOnSelection": true, + "terminal.integrated.cursorBlinking": true, + // ======================================================== + // TERMINAL (UX & Performance) + // Bash = canônico | PowerShell = instrumental / IA-friendly + // ======================================================== + "terminal.integrated.defaultProfile.linux": "bash", + "terminal.integrated.detectLocale": "off", + // "terminal.integrated.drawBoldTextInInvertedColors" removido: setting deprecado no VS Code 1.100+ + "terminal.integrated.enableMultiLinePasteWarning": "never", + // canvas: renderização estável em containers Linux (sem WebGL/GPU real). + // Consistente com .vscode/settings.json que também define "canvas". + "terminal.integrated.gpuAcceleration": "canvas", + "terminal.integrated.inheritEnv": true, + "terminal.integrated.profiles.linux": { + // ---------------------------------------------------- + // Bash — SHELL CANÔNICO (infra, automação, build) + // ---------------------------------------------------- + "bash": { + // "-l" (login shell): garante que /etc/profile.d/*.sh seja carregado + // em cada terminal integrado. Crítico para: + // • 10-gatekeeper-nss.sh → LD_PRELOAD com caminho absoluto + // (evita erros de linker em spawns intensivos de subprocesso) + // • 00-restore-env.sh → PATH correto com nvm/npm-global + // Sem -l, profile.d não recanonicaliza/refina NSS_WRAPPER_* nem + // reforça o LD_PRELOAD canônico para shells interativos. + "args": ["-l"], + "icon": "terminal-bash", + "path": "/bin/bash" + }, + // ---------------------------------------------------- + // PowerShell — INSTRUMENTAL / SEMÂNTICO / IA-FRIENDLY + // + // • Não é default + // • Não governa automação + // • Carrega contrato global automaticamente + // ---------------------------------------------------- + "pwsh": { + "args": ["-NoLogo"], + "icon": "terminal-powershell", + "path": "/usr/bin/pwsh" + } + }, + // -------------------------------------------------------- + // COMPORTAMENTO GERAL DO TERMINAL + // -------------------------------------------------------- + // ============================================================ + // TERMINAL — OPTIMIZED SETTINGS (v5.2) + // ============================================================ + // Scrollback buffer otimizado para ambiente DevContainer: + // • 10,000 linhas = ~2MB memória (scrollback típico) + // • Suficiente para debug sessions (tail -f logs, PM2, etc) + // • Redução de 50% vs. v3.4 (20,000 → 10,000) + // • Previne memory bloat em long-running containers + // + // Context: Em DevContainers, scrollback persiste enquanto o + // terminal está aberto. Com 4-5 terminais simultâneos, + // 20,000 linhas/terminal = ~40MB overhead (10,000 = ~20MB). + // + // Se precisar de mais: + // • Use 'make logs-follow' (arquivos em logs/) + // • Configure PM2 logs (ecosystem.config.js) + // • Use Docker logs (docker logs -f ) + // ============================================================ + "terminal.integrated.scrollback": 10000, + // ======================================================== + // PRETTIER (somente com configuração explícita) + // ======================================================== + // "prettier.requireConfig": true, + // ======================================================== + // TESTING (Jest + Test Explorer) + // ======================================================== + "testExplorer.useNativeTesting": false, + // ======================================================== + // YAML / JSON / SHELL (infra, contratos, scripts) + // ======================================================== + "yaml.validate": true + } + } + }, + // ============================================================ + // FEATURES + // ============================================================ + // Deliberadamente omitidas. O bloco vazio `"features": {}` faz o Dev Containers + // gerar um Dockerfile intermediário desnecessário e pode disparar warnings do + // builder sobre `ARG BASE_IMAGE` sem default. O tooling é instalado manualmente + // no Dockerfile para manter controle e evitar duplicações. + // =========================================================== + // PORT FORWARDING — CONTRATO FINAL (DENY BY DEFAULT) + // =========================================================== + // + // Este bloco NÃO é apenas configuração. + // Ele é um DOCUMENTO ARQUITETURAL VIVO. + // + // --------------------------------------------------------------------------- + // PRINCÍPIO FUNDAMENTAL + // --------------------------------------------------------------------------- + // • Política global: DENY-BY-DEFAULT + // • Nenhuma porta é exposta por acidente + // • Nenhuma inferência automática é aceitável + // • Toda porta exposta deve ter: + // - Justificativa funcional + // - Público-alvo explícito + // - Comportamento de forward declarado + // + // --------------------------------------------------------------------------- + // TOPOLOGIA FÍSICA (3 CAMADAS) + // --------------------------------------------------------------------------- + // + // WINDOWS HOST (Máquina Física) + // ├── Chrome Windows (chrome.exe, porta 9225) + // │ • Inicia: START-CHROME-SIMPLE.bat + // │ • Função: LLM Automation (ChatGPT, Gemini via Puppeteer) + // │ • Protocolo: Chrome DevTools Protocol (CDP) + // │ • Ontologia: Windows gerencia, Container conecta + // │ + // └── WSL2 (Virtual Machine Linux) + // └── Docker Desktop (Container Engine) + // └── DevContainer (Node.js Runtime) + // └── PM2 (Process Manager - 3 processos): + // │ + // ├── agente-gpt (Main Process) + // │ • Script: index.js → src/main.js + // │ • Função: Kernel + Drivers + Orchestration + NERV + // │ • Porta: NENHUMA (IPC via NERV) + // │ • Depende: Chrome Proxy (9224) + // │ + // ├── dashboard-web (Server Process) + // │ • Script: src/server/main.js + // │ • Função: HTTP + Socket.io + API REST + Telemetry + // │ • Porta: 3008 (HTTP + WebSocket) + // │ • Browser: ANY (Chrome, Firefox, Edge, Safari) + // │ • Dependências: ZERO (autônomo) + // │ + // └── chrome-proxy (Proxy Service) + // • Script: scripts/chrome-proxy-service.js + // • Função: Transparent Proxy (HTTP + WebSocket) + // • Porta IN: 9224 (container) + // • Porta OUT: 9225 (host Windows via host.docker.internal) + // • Fluxo: Puppeteer → localhost:9224 → Chrome (9225) + // + // --------------------------------------------------------------------------- + // ARQUITETURA POR PLANOS FUNCIONAIS + // --------------------------------------------------------------------------- + // + // ┌──────────────────────────────────────────────────────────┐ + // │ UI HUMANA (Interfaces interativas) │ + // │ │ + // │ 3008 → Dashboard Principal (Mission Control) │ + // │ • Processo: dashboard-web (PM2) │ + // │ • Browser: ANY (Chrome, Firefox, Edge, Safari) │ + // │ • HTTP + Socket.io + API REST │ + // │ • Dependências: ZERO (autônomo) │ + // │ │ + // │ • Auto-forward com notificação │ + // └──────────────────────────────────────────────────────────┘ + // + // ┌──────────────────────────────────────────────────────────┐ + // │ INFRAESTRUTURA (Fronteiras Arquiteturais) │ + // │ │ + // │ 9224 → Chrome Proxy Service (Container → Windows) │ + // │ • Processo: chrome-proxy (PM2) │ + // │ • Função: Proxy transparente HTTP + WebSocket │ + // │ • IN: localhost:9224 (Puppeteer conecta aqui) │ + // │ • OUT: host.docker.internal:9225 (Chrome) │ + // │ │ + // │ 9225 → Chrome Real (Windows Host) │ + // │ • Processo: chrome.exe (Windows) │ + // │ • Inicia: START-CHROME-SIMPLE.bat │ + // │ • Função: Chrome DevTools Protocol (CDP) │ + // │ • Acesso: NUNCA direto, sempre via proxy 9224 │ + // │ │ + // │ • NÃO são UI humana │ + // │ • NÃO sofrem auto-forward │ + // │ • Representam FRONTEIRAS LÓGICAS │ + // └──────────────────────────────────────────────────────────┘ + // + // ┌──────────────────────────────────────────────────────────┐ + // │ DEBUG (OPT-IN — NUNCA NOTIFICA POR PADRÃO) │ + // │ │ + // │ 9229 → Node.js Debug (Primary - agente-gpt) │ + // │ 9230 → Node.js Debug (Fallback - dashboard-web) │ + // │ │ + // │ • Exposição silenciosa │ + // │ • Uso deliberado (--inspect flag) │ + // │ • Não notifica o operador │ + // └──────────────────────────────────────────────────────────┘ + // + // --------------------------------------------------------------------------- + // NOTAS NORMATIVAS IMPORTANTES + // --------------------------------------------------------------------------- + // + // PM2 (Process Manager): + // • Organizador geral de processos Node.js + // • Gerencia 3 processos: agente-gpt, dashboard-web, chrome-proxy + // • NÃO tem porta própria (daemon interno) + // • Web dashboard opcional (porta 9615) NÃO configurado por padrão + // + // Chrome Dual Purpose (2 usos INDEPENDENTES): + // 1. Dashboard (porta 3008): + // • Humano → ANY Browser → http://localhost:3008 → dashboard-web + // • ZERO dependência de Puppeteer + // • ZERO dependência de Chrome Windows (porta 9225) + // + // 2. LLM Automation (porta 9225): + // • agente-gpt → Puppeteer → localhost:9224 → proxy → Chrome (9225) + // • Puppeteer required + // • Chrome Windows required + // + // Fluxo de Dados LLM Automation: + // Puppeteer → localhost:9224 (proxy) → host.docker.internal:9225 (Chrome) + // + // O sistema possui Chromium local como fallback técnico de imagem, mas o fluxo + // normal de automação LLM exige Chrome externo via Chrome Proxy. O fallback local + // só deve ser ativado por decisão explícita do runtime. + // + // =========================================================== + // ============================================================================ + // PORT POLICY — SCOPE & NON-GOALS (CRITICAL) + // ---------------------------------------------------------------------------- + // Este DevContainer governa APENAS portas abertas pelo próprio container. + // + // Portas pertencentes ao HOST (Windows) — ex.: Chrome DevTools (9225) — + // estão FORA do escopo técnico deste arquivo e: + // + // • NÃO podem ser forwardadas pelo VS Code + // • NÃO devem ser declaradas em forwardPorts + // • NÃO devem aparecer em portsAttributes + // • NÃO devem ser inferidas ou "protegidas" aqui + // + // A porta 9225 (Chrome Real, Windows Host): + // • É deliberadamente EXCLUÍDA deste contrato + // • Só é acessível via Chrome Proxy Service (9224) + // • Qualquer tentativa de acesso direto é violação arquitetural + // ============================================================================ + "forwardPorts": [ + 3008, // Dashboard Principal — Mission Control (HTTP + Socket.io + API) + 5173, // Vite Dev Server — Vue Dashboard (dev only) + 9224, // Chrome Proxy Service (Container → Windows Host) + 9229, // Node.js Debug — Primary (agente-gpt --inspect) + 9230 // Node.js Debug — Fallback (dashboard-web --inspect) + ], + // ============================================================ + // RESOURCE LIMITS (Proteção do Host) + // ============================================================ + "hostRequirements": { + "cpus": 4, + "memory": "8gb", + "storage": "40gb" + }, + "mounts": [ + // ============================================================================ + // NOTA NORMATIVA — MOUNTS VS VARIÁVEIS (CONTRATO DE FASE) + // ---------------------------------------------------------------------------- + // Esta seção opera no PLANO INFRAESTRUTURAL do Docker. + // + // Regras não negociáveis: + // • O Docker resolve mounts ANTES da inicialização do container + // • O campo `target=` (container side) DEVE ser caminho literal + // - Docker NÃO expande variáveis (ex.: ${containerUserHome}, $USER) + // - Docker processamento ocorre ANTES do container existir + // • O campo `source=` (host side) PODE usar variáveis VS Code + // - VS Code expande ${localEnv:*}, ${localWorkspaceFolder} ANTES de chamar docker + // - Exemplo válido: "source=${localEnv:HOME}/data,target=/data,type=bind" + // + // Implicação arquitetural: + // • `mounts` materializam decisões já tomadas (infraestrutura concreta) + // • `containerEnv` expressa semântica dinâmica (comportamento do sistema) + // + // Portanto: + // • ✅ VÁLIDO: source=${localWorkspaceFolder}/data,target=/app/data + // • ❌ INVÁLIDO: source=/data,target=${containerWorkspaceFolder}/data + // • Usar caminhos literais em `target=` coerentes com `remoteUser: "node"` + // • Centralizar abstrações dinâmicas exclusivamente em `containerEnv` + // + // Violação deste contrato resulta em erro fatal no `docker run`. + // Referência: Docker CLI docs (--mount flag), VS Code DevContainers Variables + // ============================================================================ + // ===================================================================== + // CORE — CACHE & PERFORMANCE (XDG_CACHE_HOME) + // --------------------------------------------------------------------- + // • Cache genérico de ferramentas, linguagens e utilitários + // • Acelera rebuilds e reduz reindexação + // • Seguro para purge eventual + // ===================================================================== + "source=devcontainer-cache,target=/home/node/.cache,type=volume", + // Cache específico e pesado do Puppeteer (isolado por motivo técnico) + // • Evita downloads repetidos + // • Não deve ser compartilhado com outros caches + "source=devcontainer-puppeteer-cache,target=/home/node/.cache/puppeteer,type=volume", + // Cache dedicado do TypeScript / JavaScript Language Server + // • Melhora IntelliSense e Copilot em projetos grandes + // • Reduz reindexação após rebuild + "source=devcontainer-ts-cache,target=/home/node/.cache/typescript,type=volume", + // ===================================================================== + // NODE / PACKAGE ECOSYSTEM + // --------------------------------------------------------------------- + // • Persistência correta do ecossistema Node + // • NÃO persiste node_modules (por design) + // ===================================================================== + // npm cache (respeita NPM_CONFIG_CACHE) + "source=devcontainer-npm-cache,target=/home/node/.npm,type=volume", + // npm-global (bins globais, se utilizados) + "source=devcontainer-npm-global,target=/home/node/.npm-global,type=volume", + // ===================================================================== + // PROCESS & RUNTIME STATE + // --------------------------------------------------------------------- + // • Estado de processos gerenciados (PM2) + // • Logs, dumps e PIDs + // ===================================================================== + "source=devcontainer-pm2-state,target=/home/node/.pm2,type=volume", + // ===================================================================== + // USER CONFIGURATION (XDG — CONFIG / SHARE / STATE) + // --------------------------------------------------------------------- + // • Fonte de verdade para preferências do usuário + // • Inclui GitHub Copilot, Copilot Chat e extensões + // ===================================================================== + // XDG_CONFIG_HOME + "source=devcontainer-user-config,target=/home/node/.config,type=volume", + // XDG_DATA_HOME + "source=devcontainer-local-share,target=/home/node/.local/share,type=volume", + // XDG_STATE_HOME + // • Estado transitório de ferramentas, incluindo Copilot Chat + "source=devcontainer-local-state,target=/home/node/.local/state,type=volume", + // ===================================================================== + // AI / AGENT STATE + // --------------------------------------------------------------------- + // • Estado persistente de agentes locais (Claude, etc.) + // • NÃO inclui memória semântica remota (server-side) + // ===================================================================== + "source=devcontainer-claude-state,target=/home/node/.claude,type=volume", + // ===================================================================== + // IDENTITY & SECRETS (STRICT, NÃO COPIAR) + // --------------------------------------------------------------------- + // • Chaves NUNCA são persistidas como volume + // • Apenas forwarding seguro via agent + // ===================================================================== + // ========================================================= + // SSH AGENT FORWARDING — VS CODE NATIVE (v5.3+) + // --------------------------------------------------------- + // HISTÓRICO: + // v5.2: Mount manual "source=${localEnv:SSH_AUTH_SOCK},target=/ssh-agent" + // v5.3: REMOVIDO - Causava erro fatal de type mismatch + // + // ERRO ANTERIOR: + // error mounting ... to rootfs at "/ssh-agent": not a directory: + // Are you trying to mount a directory onto a file (or vice-versa)? + // + // CAUSA: + // SSH_AUTH_SOCK é um socket UNIX (arquivo especial) + // Docker tentava montar como diretório + // Type mismatch → container não iniciava + // + // SOLUÇÃO: + // VS Code Remote Containers fornece SSH forwarding NATIVO + // Não requer mount manual + // Funciona automaticamente quando SSH agent está presente no host + // + // BENEFÍCIOS: + // ✅ Container inicia com ou sem SSH agent + // ✅ Zero configuração manual + // ✅ Fail-safe por design + // ✅ Cross-platform (Windows/Linux/macOS) + // + // VALIDAÇÃO: + // Dentro do container: + // $ echo $SSH_AUTH_SOCK # Deve mostrar path se agent disponível + // $ ssh-add -l # Lista chaves (se agent presente) + // $ ssh -T git@github.com # Testa autenticação GitHub + // ========================================================= + // GnuPG keyring (persistente, mas isolado) + "source=devcontainer-gpg-cache,target=/home/node/.gnupg,type=volume", + // ===================================================================== + // VS CODE SERVER (PERFORMANCE CRÍTICO) + // --------------------------------------------------------------------- + // • Binário do VS Code Server + // • Extensões (incluindo Copilot) + // • Estado de autenticação e UX + // + // ⚠️ Volume sensível — purge apenas se necessário. + // Geração TS7 v1: inicia o servidor remoto sem extensões herdadas do volume pré-migração. + // O volume `devcontainer-vscode-server` anterior permanece intacto para rollback/forense. + // ===================================================================== + "source=devcontainer-vscode-server-ts7-v1,target=/home/node/.vscode-server,type=volume", + // ===================================================================== + // UX — HISTÓRICO DE SHELL (FORA DO $HOME POR DESIGN) + // --------------------------------------------------------------------- + // • Histórico persistente entre rebuilds + // • Não polui o $HOME + // ===================================================================== + "source=devcontainer-bash-history,target=/home/node-history,type=volume", + // ===================================================================== + // INFRASTRUCTURE (DEV ONLY — ALTO PRIVILÉGIO) + // --------------------------------------------------------------------- + // • Docker CLI usa o socket do host + // • Equivale a root no host + // • NUNCA usar em produção + // ===================================================================== + "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" + ], + "name": "ChatGPT Docker Puppeteer - Dev Container", + // Default oficial para portas não declaradas explicitamente. + // Use otherPortsAttributes em vez de wildcard "*" dentro de portsAttributes. + "otherPortsAttributes": { + "onAutoForward": "ignore" + }, + "portsAttributes": { + // ---------------------------------------------------------- + // Política global: DENY-BY-DEFAULT + // ---------------------------------------------------------- + // • Qualquer porta NÃO declarada explicitamente é ignorada + // • Nenhuma inferência automática é permitida + // ---------------------------------------------------------- + // ================== UI HUMANA ============================== + "3008": { + "label": "Dashboard Principal — Mission Control (HTTP + Socket.io + API)", + "onAutoForward": "notify", + "protocol": "http", + "webRoot": "${containerWorkspaceFolder}/src/server" + }, + "5173": { + "label": "Vite Dev Server — Vue Dashboard (dev only)", + "onAutoForward": "notify", + "protocol": "http", + "webRoot": "${containerWorkspaceFolder}/src/dashboard-ui" + // NOTA: HMR (Hot Module Reload) configurado no vite.config.js + // - port: 5173 (HTTP + WebSocket na MESMA porta) + // - hmr.clientPort: 5173 (WebSocket fixado) + // - hmr.host: 'localhost' (Windows acesso via port forwarding) + // - watch.usePolling: true (Docker volumes file watching) + // ✅ WebSocket HMR usa a MESMA porta 5173 (não precisa forward adicional) + }, + // ================== INFRAESTRUTURA ========================= + "9224": { + "label": "Chrome Proxy Service (Container → Windows Host)", + "onAutoForward": "ignore", + "protocol": "http" + // NOTA ARQUITETURAL: + // • Esta porta ESTÁ em forwardPorts (sempre disponível) + // • onAutoForward: "ignore" = não notifica o usuário (porta interna) + // • Reconciliação conceitual: forward != auto-forward + // - forwardPorts: política de DISPONIBILIDADE (sempre expor) + // - onAutoForward: política de UX (notificar humano ou não) + // + // FUNÇÃO: + // • Proxy transparente HTTP + WebSocket (CDP) + // • Puppeteer → localhost:9224 → host.docker.internal:9225 + // • ÚNICA fronteira autorizada para Chrome externo + // • Porta 9225 (Chrome Real, Windows) NUNCA acessada diretamente + }, + // ================== DEBUG ================================== + "9229": { + "label": "Node.js Debug — Primary (agente-gpt)", + "onAutoForward": "silent", + "protocol": "http" + // NOTA: Node Inspector expõe endpoints HTTP (/json/list, /json/version) + // Não é HTTP REST típico, mas protocol: "http" evita interpretações erradas + // ⚠️ Só funciona se processo rodar com: --inspect=0.0.0.0:9229 + }, + "9230": { + "label": "Node.js Debug — Fallback (dashboard-web)", + "onAutoForward": "silent", + "protocol": "http" + // ⚠️ Só funciona se processo rodar com: --inspect=0.0.0.0:9230 + } + }, + // ============================================================ + // LIFECYCLE HOOKS - Sincronia de Inicialização + // ============================================================ + // Chamamos bash explicitamente dentro do login shell. + // Motivo: + // bash -lc '