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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions server/internal/daemon/execenv/codex_home.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,12 +241,15 @@ func prepareCodexHomeWithOpts(codexHome string, opts CodexHomeOptions, logger *s
}
}
}
// Drop `[[skills.config]]` entries inherited from the user's
// Drop user-level skill config and marketplace update sources inherited from
// ~/.codex/config.toml. Codex Desktop writes plugin-backed skills with a
// `name` and no `path`, which the CLI's stricter TOML parser rejects with
// `missing field path` and bails out of `thread/start`. Multica writes the
// agent's active skills directly to `codex-home/skills/`, so the
// user-level registry is redundant here. See codex_skill_strip.go.
// `missing field path` and bails out of `thread/start`. Marketplace entries
// also trigger redundant git refreshes in every isolated task home. Multica
// writes the agent's active skills directly to `codex-home/skills/`. Keep
// `[plugins.*]` so installed plugins still load from the exposed shared
// plugin cache, but do not let tasks update marketplaces. See
// codex_skill_strip.go.
if err := sanitizeCopiedCodexConfig(filepath.Join(codexHome, "config.toml")); err != nil {
logger.Warn("execenv: codex-home sanitize config failed", "error", err)
}
Expand Down
247 changes: 211 additions & 36 deletions server/internal/daemon/execenv/codex_skill_strip.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package execenv

import (
"bytes"
"fmt"
"os"
"strings"

"github.com/pelletier/go-toml/v2"
"github.com/pelletier/go-toml/v2/unstable"
)

// stripSkillsConfigEntries removes every `[[skills.config]]` array-of-tables
// block from the given config.toml content.
// stripTaskIrrelevantCodexConfigEntries removes user-level skill config and
// marketplace update sources from copied config.toml content.
//
// Background: Codex Desktop writes one `[[skills.config]]` entry per skill it
// knows about — file-backed skills get a `path = "..."` field, while
Expand All @@ -24,50 +27,219 @@ import (
// that directory. The user-level skill registry is irrelevant to a per-task
// run, so dropping it is both safe and the right scope of isolation.
//
// Lines outside `[[skills.config]]` blocks are preserved untouched.
func stripSkillsConfigEntries(content string) string {
if !strings.Contains(content, "[[skills.config]]") {
return content
// Multica materializes the agent's assigned skills directly under the per-task
// CODEX_HOME/skills. User-level `[marketplaces.*]` entries are also unsafe in a
// task copy because Codex probes or clones each git source before the first
// turn. `[plugins.*]` entries remain intact so installed plugins still load
// from the shared plugin cache exposed by prepareCodexHomeWithOpts.
//
// Source ranges preserve comments and every unrelated expression byte-for-byte
// while supporting all legal TOML key forms, including dotted keys, quoted
// tables, inline root values, and array tables.
func stripTaskIrrelevantCodexConfigEntries(content []byte) ([]byte, error) {
if len(bytes.TrimSpace(content)) == 0 {
return content, nil
}

lines := strings.Split(content, "\n")
out := make([]string, 0, len(lines))
inSkillsConfig := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
var decoded map[string]any
if err := toml.Unmarshal(content, &decoded); err != nil {
return nil, fmt.Errorf("parse config.toml before task sanitization: %w", err)
}

// A new TOML header always closes the current `[[skills.config]]`
// block, regardless of whether it's another entry of the same array
// or a different table.
if strings.HasPrefix(trimmed, "[") {
if trimmed == "[[skills.config]]" {
inSkillsConfig = true
var parser unstable.Parser
parser.Reset(content)
var currentTable []string
ranges := make([]tomlByteRange, 0, 16)
for parser.NextExpression() {
expr := parser.Expression()
keys := tomlNodeKeys(expr)
switch expr.Kind {
case unstable.Table, unstable.ArrayTable:
currentTable = keys
if isTaskIrrelevantCodexConfigPath(keys) {
r, err := tomlTableHeaderLineRange(content, expr)
if err != nil {
return nil, fmt.Errorf("locate task-irrelevant config table: %w", err)
}
r.start = precedingBlankLinesStart(content, r.start)
ranges = append(ranges, r)
}
case unstable.KeyValue:
fullKey := make([]string, 0, len(currentTable)+len(keys))
fullKey = append(fullKey, currentTable...)
fullKey = append(fullKey, keys...)
if len(fullKey) == 1 && fullKey[0] == "skills" && expr.Value().Kind == unstable.InlineTable {
inlineRanges, removeExpression, err := taskIrrelevantInlineSkillsRanges(content, expr)
if err != nil {
return nil, fmt.Errorf("locate inline skills.config: %w", err)
}
if removeExpression {
ranges = append(ranges, tomlExpressionLineRange(content, expr))
} else {
ranges = append(ranges, inlineRanges...)
}
continue
}
inSkillsConfig = false
out = append(out, line)
continue
if isTaskIrrelevantCodexConfigPath(fullKey) {
ranges = append(ranges, tomlExpressionLineRange(content, expr))
}
}
}
if err := parser.Error(); err != nil {
return nil, fmt.Errorf("parse config.toml task sanitization expressions: %w", err)
}
stripped, err := removeTOMLByteRanges(content, ranges)
if err != nil {
return nil, fmt.Errorf("remove task-irrelevant config expressions: %w", err)
}
if err := toml.Unmarshal(stripped, &decoded); err != nil {
return nil, fmt.Errorf("validate config.toml after task sanitization: %w", err)
}
if len(ranges) > 0 {
stripped = bytes.TrimLeft(stripped, "\n")
stripped = bytes.TrimRight(stripped, "\n")
if len(stripped) > 0 {
stripped = append(stripped, '\n')
}
}
return stripped, nil
}

// taskIrrelevantInlineSkillsRanges locates config entries inside a top-level
// inline skills table. Removing the whole expression is safe only when every
// entry belongs to skills.config; otherwise the returned ranges preserve the
// remaining inline table byte-for-byte.
func taskIrrelevantInlineSkillsRanges(content []byte, expression *unstable.Node) ([]tomlByteRange, bool, error) {
table := expression.Value()
entries := make([]*unstable.Node, 0, 4)
targets := make([]bool, 0, 4)
targetCount := 0
for it := table.Children(); it.Next(); {
entry := it.Node()
if entry.Kind != unstable.KeyValue {
return nil, false, fmt.Errorf("inline skills table contains %s instead of key-value", entry.Kind)
}
keys := tomlNodeKeys(entry)
isTarget := len(keys) > 0 && keys[0] == "config"
entries = append(entries, entry)
targets = append(targets, isTarget)
if isTarget {
targetCount++
}
}
if targetCount == 0 {
return nil, false, nil
}
if targetCount == len(entries) {
return nil, true, nil
}

if inSkillsConfig {
closingBrace, err := tomlInlineTableClosingBrace(content, expression, table)
if err != nil {
return nil, false, err
}
ranges := make([]tomlByteRange, 0, targetCount)
for index, entry := range entries {
if !targets[index] {
continue
}
start, err := firstTOMLKeyOffset(content, entry)
if err != nil {
return nil, false, err
}
if index+1 < len(entries) {
end, err := firstTOMLKeyOffset(content, entries[index+1])
if err != nil {
return nil, false, err
}
ranges = append(ranges, tomlByteRange{start: start, end: end})
continue
}
out = append(out, line)

// The last entry has no following key whose offset can delimit it.
// Include its preceding separator and stop immediately before the
// inline table's closing brace.
separator := start
for separator > 0 && (content[separator-1] == ' ' || content[separator-1] == '\t') {
separator--
}
if separator == 0 || content[separator-1] != ',' {
return nil, false, fmt.Errorf("last inline skills.config entry has no preceding comma")
}
end := closingBrace
for end > start && (content[end-1] == ' ' || content[end-1] == '\t') {
end--
}
ranges = append(ranges, tomlByteRange{start: separator - 1, end: end})
}
return ranges, false, nil
}

stripped := strings.Join(out, "\n")
// Collapse the trailing blank-line cluster that the removal can leave
// behind so repeated copies don't grow the file unboundedly.
stripped = strings.TrimRight(stripped, "\n") + "\n"
if strings.TrimSpace(stripped) == "" {
return ""
}
return stripped
func firstTOMLKeyOffset(content []byte, keyValue *unstable.Node) (int, error) {
it := keyValue.Key()
if !it.Next() {
return 0, fmt.Errorf("inline key-value has no key")
}
offset := int(it.Node().Raw.Offset)
if offset < 0 || offset > len(content) {
return 0, fmt.Errorf("inline key offset is outside config")
}
return offset, nil
}

// tomlInlineTableClosingBrace derives the value boundary from go-toml's full
// KeyValue source range instead of duplicating TOML string grammar locally.
func tomlInlineTableClosingBrace(content []byte, expression, table *unstable.Node) (int, error) {
start := int(table.Raw.Offset)
if start < 0 || start >= len(content) || content[start] != '{' {
return 0, fmt.Errorf("inline table opening brace is outside config")
}
end := int(expression.Raw.Offset) + int(expression.Raw.Length)
if end <= start || end > len(content) || content[end-1] != '}' {
return 0, fmt.Errorf("inline table closing brace is outside key-value range")
}
return end - 1, nil
}

func tomlExpressionLineRange(content []byte, expr *unstable.Node) tomlByteRange {
start := int(expr.Raw.Offset)
end := start + int(expr.Raw.Length)
start = bytes.LastIndexByte(content[:start], '\n') + 1
if end < len(content) {
if newline := bytes.IndexByte(content[end:], '\n'); newline >= 0 {
end += newline + 1
}
}
return tomlByteRange{start: start, end: end}
}

func precedingBlankLinesStart(content []byte, start int) int {
for start > 0 {
lineEnd := start - 1
lineStart := bytes.LastIndexByte(content[:lineEnd], '\n') + 1
if len(bytes.TrimSpace(content[lineStart:lineEnd])) != 0 {
break
}
start = lineStart
}
return start
}

func isTaskIrrelevantCodexConfigPath(keys []string) bool {
if len(keys) == 0 {
return false
}
if keys[0] == "marketplaces" {
return true
}
return len(keys) >= 2 && keys[0] == "skills" && keys[1] == "config"
}

// sanitizeCopiedCodexConfig rewrites the per-task config.toml in place,
// dropping `[[skills.config]]` entries inherited from the shared
// `~/.codex/config.toml`. No-op if the file doesn't exist or doesn't change.
// dropping user-level skill config and marketplace update sources inherited
// from the shared `~/.codex/config.toml`. Installed plugin entries remain so
// they can load from the shared plugin cache. No-op if the file doesn't exist
// or doesn't change.
func sanitizeCopiedCodexConfig(configPath string) error {
data, err := os.ReadFile(configPath)
if err != nil {
Expand All @@ -76,11 +248,14 @@ func sanitizeCopiedCodexConfig(configPath string) error {
}
return fmt.Errorf("read config.toml: %w", err)
}
stripped := stripSkillsConfigEntries(string(data))
if stripped == string(data) {
stripped, err := stripTaskIrrelevantCodexConfigEntries(data)
if err != nil {
return err
}
if bytes.Equal(stripped, data) {
return nil
}
if err := os.WriteFile(configPath, []byte(stripped), 0o644); err != nil {
if err := os.WriteFile(configPath, stripped, 0o644); err != nil {
return fmt.Errorf("write config.toml: %w", err)
}
return nil
Expand Down
Loading
Loading