fix(runtime): repair package installs, archives, logs, and telegram startup

Merge runtime/package install fixes, archive attachment handling, operator log access, and Telegram startup IPv4 fallback.
This commit is contained in:
Duy /zuey/ authored and GitHub committed 2026-05-20 21:26:10 +07:00
1 parent 532ff91d8e
commit ce5ea10d46
12 files changed
+499 -43

No files matched your search

+22 -3
View File
@@ -3,6 +3,7 @@ package cmd
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
@@ -49,6 +50,25 @@ import (
_ "github.com/nextlevelbuilder/goclaw/internal/workstation/backends"
)
func gatewayLogOutput() io.Writer {
logFile := strings.TrimSpace(os.Getenv("GOCLAW_LOG_FILE"))
if logFile == "" {
if st, err := os.Stat("/var/log/goclaw"); err == nil && st.IsDir() {
logFile = "/var/log/goclaw/goclaw.log"
}
}
if logFile == "" {
return os.Stdout
}
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: cannot open GOCLAW_LOG_FILE=%q: %v\n", logFile, err)
return os.Stdout
}
fmt.Fprintf(os.Stderr, "logging to %s\n", logFile)
return io.MultiWriter(os.Stdout, f)
}
func runGateway() {
// Setup structured logging
logLevel := slog.LevelInfo
@@ -70,9 +90,8 @@ func runGateway() {
fmt.Fprintf(os.Stderr, "warning: unknown GOCLAW_LOG_LEVEL=%q, using info\n", lvl)
}
}
textHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: logLevel,
})
logOutput := gatewayLogOutput()
textHandler := slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: logLevel})
logTee := gateway.NewLogTee(textHandler)
slog.SetDefault(slog.New(logTee))
+1 -1
View File
@@ -592,7 +592,7 @@ func buildToolingSection(toolNames []string, hasSandbox bool, shellDenyGroups ma
lines = append(lines,
"",
"### Media Files",
`When users send media (<media:image path="...">, <media:video id="...">, <media:audio id="...">, <media:document path="...">), use the corresponding read_* tool with the path/media_id.`,
`When users send media (<media:image path="...">, <media:video id="...">, <media:audio id="...">, <media:document path="...">), use the corresponding read_* tool with the path/media_id. For archives (.zip, .tar.gz, etc.), use exec with the document path to inspect/extract the archive.`,
"You have full vision/audio/video capabilities. NEVER say you cannot see images or files.",
)
}
+16
View File
@@ -102,6 +102,9 @@ func ExtractDocumentContent(filePath, fileName string) (string, error) {
if !isText {
// Binary files (PDF, DOCX, etc.) are persisted via MediaRef and analyzed
// by the read_document tool. Return a hint instead of "not supported" placeholder.
if isArchiveFileName(fileName) {
return fmt.Sprintf("[Archive: %s — use exec with the path from the <media:document> tag to inspect or extract this archive]", fileName), nil
}
return fmt.Sprintf("[File: %s — use read_document tool to analyze this file]", fileName), nil
}
@@ -122,3 +125,16 @@ func ExtractDocumentContent(filePath, fileName string) (string, error) {
return fmt.Sprintf("<file name=%q mime=%q>\n%s\n</file>", fileName, mime, escaped), nil
}
func isArchiveFileName(fileName string) bool {
lower := strings.ToLower(fileName)
return strings.HasSuffix(lower, ".zip") ||
strings.HasSuffix(lower, ".tar") ||
strings.HasSuffix(lower, ".tar.gz") ||
strings.HasSuffix(lower, ".tgz") ||
strings.HasSuffix(lower, ".tar.bz2") ||
strings.HasSuffix(lower, ".tbz2") ||
strings.HasSuffix(lower, ".tar.xz") ||
strings.HasSuffix(lower, ".txz") ||
strings.HasSuffix(lower, ".gz")
}
+68 -12
View File
@@ -2,6 +2,7 @@ package telegram
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
@@ -28,7 +29,7 @@ type Channel struct {
config config.TelegramConfig
httpClient *http.Client
transport *http.Transport
ipv4Once sync.Once // guards enableIPv4Only to prevent data race
ipv4Once sync.Once // guards enableIPv4Only to prevent data race
agentStore store.AgentStore // for agent key lookup (nil if not configured)
configPermStore store.ConfigPermissionStore // for group file writer management (nil if not configured)
teamStore store.TeamStore // for /tasks, /task_detail commands (nil if not configured)
@@ -38,16 +39,16 @@ type Channel struct {
typingCtrls sync.Map // localKey string → *typing.Controller
reactions sync.Map // localKey string → *StatusReactionController
threadIDs sync.Map // localKey string → messageThreadID int (for forum topic routing)
mentionMode string // "strict" (default) or "yield"
botDisplayName string // bot's first_name from GetMe (e.g. "ViệtBot"); captured once at Start
pollCancel context.CancelFunc // cancels the long polling context
pollDone chan struct{} // closed when polling goroutine exits
handlerWg sync.WaitGroup // tracks in-flight handler goroutines for graceful shutdown
handlerSem chan struct{} // bounded semaphore for concurrent handler goroutines
pendingDraftID sync.Map // localKey string → int (draftID)
audioMgr *audio.Manager // unified STT via audio.Manager (nil = no STT)
writerHealMu sync.Mutex // guards writerHealLastTry for /writers self-heal
writerHealLastTry map[string]time.Time // key "chatID|userID" → last attempt timestamp
mentionMode string // "strict" (default) or "yield"
botDisplayName string // bot's first_name from GetMe (e.g. "ViệtBot"); captured once at Start
pollCancel context.CancelFunc // cancels the long polling context
pollDone chan struct{} // closed when polling goroutine exits
handlerWg sync.WaitGroup // tracks in-flight handler goroutines for graceful shutdown
handlerSem chan struct{} // bounded semaphore for concurrent handler goroutines
pendingDraftID sync.Map // localKey string → int (draftID)
audioMgr *audio.Manager // unified STT via audio.Manager (nil = no STT)
writerHealMu sync.Mutex // guards writerHealLastTry for /writers self-heal
writerHealLastTry map[string]time.Time // key "chatID|userID" → last attempt timestamp
// pairingService, approvedGroups, pairingDebounce, groupHistory, historyLimit, requireMention
// are inherited from channels.BaseChannel.
}
@@ -185,7 +186,17 @@ func (c *Channel) Start(ctx context.Context) error {
me, err := c.bot.GetMe(probeCtx)
probeCancel()
if err != nil {
return fmt.Errorf("validate telegram bot: %w", err)
if shouldRetryTelegramStartupWithIPv4(err) {
c.enableIPv4Only()
slog.Warn("telegram: startup probe retrying with IPv4 fallback", "error", sanitizeTelegramError(err, c.config.Token))
probeCtx, probeCancel = context.WithTimeout(ctx, probeOverallTimeout)
me, err = c.bot.GetMe(probeCtx)
probeCancel()
}
}
if err != nil {
return fmt.Errorf("validate telegram bot: %w", sanitizeTelegramError(err, c.config.Token))
}
username := ""
if me != nil {
@@ -405,6 +416,51 @@ func connectedSummary(username string) string {
return fmt.Sprintf("Connected as @%s", username)
}
type redactedTelegramError struct {
err error
token string
}
func (e redactedTelegramError) Error() string {
msg := e.err.Error()
if e.token != "" {
msg = strings.ReplaceAll(msg, e.token, "<telegram-token>")
}
return msg
}
func (e redactedTelegramError) Unwrap() error { return e.err }
func sanitizeTelegramError(err error, token string) error {
if err == nil {
return nil
}
return redactedTelegramError{err: err, token: token}
}
func shouldRetryTelegramStartupWithIPv4(err error) bool {
if err == nil {
return false
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return false
}
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "lookup") || strings.Contains(msg, "no such host") {
return false
}
if isRetryableNetworkErr(err) {
return true
}
if strings.Contains(msg, "network is unreachable") ||
strings.Contains(msg, "no route to host") ||
strings.Contains(msg, "deadline exceeded") {
return true
}
return false
}
// applyIPv4Dialer forces a transport to use IPv4 only by overriding DialContext.
func applyIPv4Dialer(t *http.Transport) {
dialer := &net.Dialer{
@@ -0,0 +1,50 @@
package telegram
import (
"context"
"errors"
"fmt"
"net"
"strings"
"testing"
)
func TestShouldRetryTelegramStartupWithIPv4ForPostConnectNetworkError(t *testing.T) {
err := fmt.Errorf(`telego: getMe: internal execution: request call: http do request: Post "https://api.telegram.org/bot123:ABC/getMe": read tcp [2407::1]:39268->[2001:67c:4e8:f004::9]:443: read: connection reset by peer`)
if !shouldRetryTelegramStartupWithIPv4(err) {
t.Fatal("expected IPv4 startup retry for connection reset after IPv6 connect")
}
}
func TestShouldRetryTelegramStartupWithIPv4SkipsDNSFailures(t *testing.T) {
err := &net.DNSError{Err: "no such host", Name: "api.telegram.org", IsNotFound: true}
if shouldRetryTelegramStartupWithIPv4(err) {
t.Fatal("expected DNS failures to avoid IPv4 retry")
}
}
func TestShouldRetryTelegramStartupWithIPv4SkipsAuthFailures(t *testing.T) {
err := errors.New(`telego: getMe: api: 401 "Unauthorized"`)
if shouldRetryTelegramStartupWithIPv4(err) {
t.Fatal("expected auth failures to avoid IPv4 retry")
}
}
func TestSanitizeTelegramErrorRedactsTokenAndPreservesCause(t *testing.T) {
token := "123456:ABC"
raw := fmt.Errorf(`Post "https://api.telegram.org/bot%s/getMe": %w`, token, context.DeadlineExceeded)
err := sanitizeTelegramError(raw, token)
if strings.Contains(err.Error(), token) {
t.Fatalf("expected token to be redacted, got %q", err.Error())
}
if !strings.Contains(err.Error(), "<telegram-token>") {
t.Fatalf("expected redaction marker, got %q", err.Error())
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatal("expected sanitized error to preserve wrapped cause")
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ var (
debPackageNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9+.-]*$`)
systemLookPath = exec.LookPath
systemCommandCombinedOutput = runSystemCommandCombinedOutput
aptSystemPackageAliases = map[string]string{"pip3": "python3-pip", "github-cli": "gh"}
aptSystemPackageAliases = map[string]string{"pip3": "python3-pip", "github-cli": "gh", "go": "golang-go", "golang": "golang-go"}
errSystemPackageMgrUnavailable = "system package manager unavailable on this runtime"
)
@@ -32,6 +32,8 @@ func TestResolveDebianPackageNameAliases(t *testing.T) {
tests := map[string]string{
"pip3": "python3-pip",
"github-cli": "gh",
"go": "golang-go",
"golang": "golang-go",
"ripgrep": "ripgrep",
"libstdc++": "libstdc++",
}
+13 -1
View File
@@ -94,6 +94,10 @@ func (t *ReadDocumentTool) Parameters() map[string]any {
"type": "string",
"description": "Optional: specific media_id from <media:document> tag. If omitted, uses most recent document.",
},
"path": map[string]any{
"type": "string",
"description": "Optional file path from a <media:document path=\"...\"> tag. Use this when the tag provides a path or the file is an archive that should be inspected with exec.",
},
},
"required": []string{"prompt"},
}
@@ -105,15 +109,23 @@ func (t *ReadDocumentTool) Execute(ctx context.Context, args map[string]any) *Re
prompt = "Analyze this document and describe its contents."
}
mediaID, _ := args["media_id"].(string)
docPathArg, _ := args["path"].(string)
// Resolve document file path from MediaRefs in context.
docPath, docMime, err := t.resolveDocumentFile(ctx, mediaID)
docPath, docMime, err := t.resolveDocumentFile(ctx, mediaID, docPathArg)
if err != nil {
return ErrorResult(err.Error())
}
slog.Info("read_document: resolved file", "path", docPath, "mime", docMime, "media_id", mediaID)
if isArchiveDocumentPath(docPath) {
return NewResult(fmt.Sprintf(
"Archive file available at %s. read_document does not analyze archive containers directly. Use exec to inspect or extract it, for example: unzip -l %q or unzip -q %q -d <output-dir>, then use list_files/read_file on extracted files.",
docPath, docPath, docPath,
))
}
// Read document file.
data, err := os.ReadFile(docPath)
if err != nil {
+91 -4
View File
@@ -5,30 +5,46 @@ import (
"encoding/base64"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// resolveDocumentFile finds the document file path from context MediaRefs.
func (t *ReadDocumentTool) resolveDocumentFile(ctx context.Context, mediaID string) (path, mime string, err error) {
// resolveDocumentFile finds the document file path from an explicit workspace
// path or from context MediaRefs.
func (t *ReadDocumentTool) resolveDocumentFile(ctx context.Context, mediaID, docPath string) (path, mime string, err error) {
if docPath != "" {
p, err := resolveDocumentPathArg(ctx, docPath)
if err != nil {
return "", "", err
}
return p, mimeFromDocExt(filepath.Ext(p)), nil
}
refs := MediaDocRefsFromCtx(ctx)
if len(refs) == 0 {
return "", "", fmt.Errorf("no documents available in this conversation. The user may not have sent a document.")
}
if strings.Contains(mediaID, "<") || strings.Contains(mediaID, "media:") {
slog.Debug("read_document: sanitizing tag-like media_id", "raw", mediaID)
mediaID = ""
}
// Find specific media_id or use most recent document.
var ref *providers.MediaRef
if mediaID != "" {
for i := range refs {
if refs[i].ID == mediaID {
if documentRefMatches(refs[i], mediaID) {
ref = &refs[i]
break
}
}
if ref == nil {
return "", "", fmt.Errorf("document with media_id %q not found in conversation", mediaID)
slog.Warn("read_document: media_id not found, falling back to most recent", "media_id", mediaID)
ref = &refs[len(refs)-1]
}
} else {
// Use the last (most recent) document ref.
@@ -57,6 +73,71 @@ func (t *ReadDocumentTool) resolveDocumentFile(ctx context.Context, mediaID stri
return p, mime, nil
}
func resolveDocumentPathArg(ctx context.Context, path string) (string, error) {
workspace := ToolWorkspaceFromCtx(ctx)
resolved, err := resolvePathWithAllowed(path, workspace, effectiveRestrict(ctx, true), allowedWithTeamWorkspace(ctx, nil))
if err != nil {
return "", fmt.Errorf("invalid document path: %w", err)
}
if err := checkDeniedPath(resolved, workspace, nil); err != nil {
return "", err
}
if info, err := os.Stat(resolved); err != nil {
return "", fmt.Errorf("failed to stat document file: %w", err)
} else if info.IsDir() {
return "", fmt.Errorf("document path is a directory: %s", path)
}
return resolved, nil
}
func documentRefMatches(ref providers.MediaRef, mediaID string) bool {
if ref.ID == mediaID {
return true
}
if ref.Path == "" {
return false
}
want := strings.ToLower(filepath.Base(mediaID))
got := strings.ToLower(filepath.Base(ref.Path))
if want == got {
return true
}
return strings.EqualFold(stripUploadShortID(got), want)
}
func stripUploadShortID(name string) string {
ext := filepath.Ext(name)
stem := strings.TrimSuffix(name, ext)
idx := strings.LastIndexByte(stem, '-')
if idx < 0 || len(stem)-idx-1 != 8 {
return name
}
for _, r := range stem[idx+1:] {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return name
}
}
return stem[:idx] + ext
}
func isArchiveDocumentPath(path string) bool {
lower := strings.ToLower(path)
switch {
case strings.HasSuffix(lower, ".zip"),
strings.HasSuffix(lower, ".tar"),
strings.HasSuffix(lower, ".tar.gz"),
strings.HasSuffix(lower, ".tgz"),
strings.HasSuffix(lower, ".tar.bz2"),
strings.HasSuffix(lower, ".tbz2"),
strings.HasSuffix(lower, ".tar.xz"),
strings.HasSuffix(lower, ".txz"),
strings.HasSuffix(lower, ".gz"):
return true
default:
return false
}
}
// callProvider dispatches document analysis to the appropriate provider API.
// For Gemini: uses native generateContent API (supports PDF natively).
// For others: uses standard Chat API with base64 document.
@@ -128,6 +209,12 @@ func mimeFromDocExt(ext string) string {
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
case ".csv":
return "text/csv"
case ".zip":
return "application/zip"
case ".tar":
return "application/x-tar"
case ".gz", ".tgz":
return "application/gzip"
default:
return "application/octet-stream"
}
@@ -0,0 +1,110 @@
package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func TestResolveDocumentFileAcceptsWorkspacePath(t *testing.T) {
workspace := t.TempDir()
uploadDir := filepath.Join(workspace, ".uploads")
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
t.Fatal(err)
}
docPath := filepath.Join(uploadDir, "codex-9c8914a5.zip")
if err := os.WriteFile(docPath, []byte("zip bytes"), 0o644); err != nil {
t.Fatal(err)
}
wantPath, err := filepath.EvalSymlinks(docPath)
if err != nil {
t.Fatal(err)
}
tool := NewReadDocumentTool(nil, nil)
ctx := WithToolWorkspace(context.Background(), workspace)
gotPath, gotMime, err := tool.resolveDocumentFile(ctx, "", ".uploads/codex-9c8914a5.zip")
if err != nil {
t.Fatalf("resolveDocumentFile returned error: %v", err)
}
if gotPath != wantPath {
t.Fatalf("path = %q, want %q", gotPath, wantPath)
}
if gotMime != "application/zip" {
t.Fatalf("mime = %q, want application/zip", gotMime)
}
}
func TestResolveDocumentFileMatchesUploadedFilenameAlias(t *testing.T) {
workspace := t.TempDir()
docPath := filepath.Join(workspace, ".uploads", "codex-9c8914a5.zip")
refs := []providers.MediaRef{{
ID: uuid.NewString(),
Kind: "document",
MimeType: "application/zip",
Path: docPath,
}}
tool := NewReadDocumentTool(nil, nil)
ctx := WithMediaDocRefs(context.Background(), refs)
gotPath, gotMime, err := tool.resolveDocumentFile(ctx, "codex.zip", "")
if err != nil {
t.Fatalf("resolveDocumentFile returned error: %v", err)
}
if gotPath != docPath {
t.Fatalf("path = %q, want %q", gotPath, docPath)
}
if gotMime != "application/zip" {
t.Fatalf("mime = %q, want application/zip", gotMime)
}
}
func TestResolveDocumentFileInvalidMediaIDFallsBackToMostRecent(t *testing.T) {
refs := []providers.MediaRef{
{ID: uuid.NewString(), Kind: "document", Path: "/workspace/.uploads/old.pdf", MimeType: "application/pdf"},
{ID: uuid.NewString(), Kind: "document", Path: "/workspace/.uploads/latest.pdf", MimeType: "application/pdf"},
}
tool := NewReadDocumentTool(nil, nil)
ctx := WithMediaDocRefs(context.Background(), refs)
gotPath, _, err := tool.resolveDocumentFile(ctx, "not-a-real-media-id", "")
if err != nil {
t.Fatalf("resolveDocumentFile returned error: %v", err)
}
if gotPath != refs[1].Path {
t.Fatalf("path = %q, want most recent %q", gotPath, refs[1].Path)
}
}
func TestReadDocumentArchiveReturnsExecHint(t *testing.T) {
workspace := t.TempDir()
docPath := filepath.Join(workspace, ".uploads", "codex-9c8914a5.zip")
if err := os.MkdirAll(filepath.Dir(docPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(docPath, []byte("zip bytes"), 0o644); err != nil {
t.Fatal(err)
}
tool := NewReadDocumentTool(nil, nil)
ctx := WithToolWorkspace(context.Background(), workspace)
ctx = store.WithTenantID(store.WithAgentID(ctx, uuid.New()), uuid.New())
result := tool.Execute(ctx, map[string]any{
"prompt": "Inspect this archive",
"path": ".uploads/codex-9c8914a5.zip",
})
if result.IsError {
t.Fatalf("expected archive hint, got error: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "unzip -l") || !strings.Contains(result.ForLLM, docPath) {
t.Fatalf("expected unzip hint with path, got: %s", result.ForLLM)
}
}
+35 -21
View File
@@ -206,6 +206,27 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result {
// Unicode-based pattern bypass while preserving functional command content.
normalizedCommand := normalizeCommand(command)
// Credentialed exec is argv-based, not shell-based. Route it before shell
// deny scanning so ordinary argument text cannot be mistaken for executable
// shell syntax.
if cred, binary, cmdArgs := t.lookupCredentialedBinary(ctx, command); cred != nil {
cwd := ToolWorkspaceFromCtx(ctx)
if cwd == "" {
cwd = t.workspace
}
if wd, _ := args["working_dir"].(string); wd != "" {
if effectiveRestrict(ctx, t.restrict) {
if resolved, err := resolvePath(wd, t.workspace, true); err == nil {
cwd = resolved
}
} else {
cwd = wd
}
}
sandboxKey := ToolSandboxKeyFromCtx(ctx)
return t.executeCredentialed(ctx, cred, binary, cmdArgs, cwd, sandboxKey, command)
}
// Resolve deny patterns: merge per-agent context overrides with global
// config (per-key agent precedence), fallback to all registry defaults.
denyOverrides := t.effectiveDenyGroups(ctx)
@@ -288,26 +309,6 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result {
return SilentResult(hint)
}
// Credentialed exec: if command matches a configured binary, use Direct Exec Mode.
// This bypasses approval (admin trust) and shell (security).
if cred, binary, cmdArgs := t.lookupCredentialedBinary(ctx, command); cred != nil {
cwd := ToolWorkspaceFromCtx(ctx)
if cwd == "" {
cwd = t.workspace
}
if wd, _ := args["working_dir"].(string); wd != "" {
if effectiveRestrict(ctx, t.restrict) {
if resolved, err := resolvePath(wd, t.workspace, true); err == nil {
cwd = resolved
}
} else {
cwd = wd
}
}
sandboxKey := ToolSandboxKeyFromCtx(ctx)
return t.executeCredentialed(ctx, cred, binary, cmdArgs, cwd, sandboxKey, command)
}
// Secure CLI gate: registered-but-not-granted binaries MUST NOT fall through
// to host exec with parent env. Works on the already-normalized command
// (Red Team F6) and unwraps shell wrappers up to depth 3 (Red Team F1).
@@ -414,6 +415,15 @@ func matchesAny(command string, patterns []*regexp.Regexp) bool {
return false
}
func posixShellPath() (string, error) {
for _, candidate := range []string{"/bin/sh", "/usr/bin/sh"} {
if st, err := os.Stat(candidate); err == nil && !st.IsDir() && st.Mode()&0o111 != 0 {
return candidate, nil
}
}
return "", errors.New("no executable POSIX shell found at /bin/sh or /usr/bin/sh")
}
// executeOnHost runs a command directly on the host (original behavior).
// ctx cancellation (e.g. agent abort) triggers SIGTERM → 3s grace → SIGKILL on the
// entire process group so forked children are also cleaned up (no orphans).
@@ -428,7 +438,11 @@ func (t *ExecTool) executeOnHost(ctx context.Context, command, cwd string) *Resu
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/C", command)
} else {
cmd = exec.Command("sh", "-c", command)
shellPath, err := posixShellPath()
if err != nil {
return ErrorResult(err.Error())
}
cmd = exec.Command(shellPath, "-c", command)
}
cmd.Dir = cwd
@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
@@ -184,6 +186,94 @@ func TestExec_GrantedBinary_UsesCredentialedPath(t *testing.T) {
}
}
func TestExec_CredentialedArgsDoNotTriggerPackageInstallApproval(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell script fixture is POSIX-only")
}
tool, ctx := newCredentialedZernioTestTool(t)
result := tool.Execute(ctx, map[string]any{
"command": `zernio posts:create --accounts abc --text "install with npm install -g agent-browser"`,
})
if result.IsError {
t.Fatalf("credentialed command should treat quoted package install text as argv data, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "posts:create") || !strings.Contains(result.ForLLM, "npm install -g agent-browser") {
t.Fatalf("expected script to receive original args, got: %s", result.ForLLM)
}
}
func TestExec_CredentialedRejectsUnquotedShellOperator(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell script fixture is POSIX-only")
}
tool, ctx := newCredentialedZernioTestTool(t)
result := tool.Execute(ctx, map[string]any{
"command": `zernio posts:create --text ok ; echo leaked`,
})
if !result.IsError {
t.Fatalf("expected credentialed command with unquoted shell operator to fail")
}
if !strings.Contains(result.ForLLM, "Shell operators not supported") && !strings.Contains(result.ForLLM, "Detected: ;") {
t.Fatalf("expected shell-operator rejection, got: %s", result.ForLLM)
}
}
func TestExec_NonCredentialedPackageInstallStillDenied(t *testing.T) {
tool := NewExecTool(t.TempDir(), false)
result := tool.Execute(context.Background(), map[string]any{
"command": "npm install left-pad",
})
if !result.IsError {
t.Fatalf("expected package install command to be denied")
}
if !strings.Contains(result.ForLLM, "package_install") && !strings.Contains(result.ForLLM, "matches pattern") {
t.Fatalf("expected package-install policy denial, got: %s", result.ForLLM)
}
}
func TestPosixShellPathPrefersAbsoluteShell(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX-only")
}
got, err := posixShellPath()
if err != nil {
t.Fatalf("expected absolute POSIX shell on test runtime: %v", err)
}
if got != "/bin/sh" && got != "/usr/bin/sh" {
t.Fatalf("unexpected shell path: %q", got)
}
if _, err := os.Stat(got); err != nil {
t.Fatalf("shell path %q must exist: %v", got, err)
}
}
func newCredentialedZernioTestTool(t *testing.T) (*ExecTool, context.Context) {
t.Helper()
binDir := t.TempDir()
binPath := filepath.Join(binDir, "zernio")
if err := os.WriteFile(binPath, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\"\n"), 0o755); err != nil {
t.Fatal(err)
}
stub := newStubSecureCLIStore()
stub.byName["zernio"] = &store.SecureCLIBinary{
BinaryName: "zernio",
BinaryPath: &binPath,
EncryptedEnv: []byte("{}"),
TimeoutSeconds: 10,
DenyArgs: json.RawMessage("[]"),
DenyVerbose: json.RawMessage("[]"),
}
tool := NewExecTool(t.TempDir(), false)
tool.SetSecureCLIStore(stub)
ctx := store.WithTenantID(store.WithAgentID(context.Background(), uuid.New()), uuid.New())
return tool, ctx
}
// --- Phase 3 gate-enforcement tests ---
// helper: build a gate-wired ExecTool with a fresh stub + ctx.