Files
ragflow/internal/agent/sandbox/tenki.go
yiming wang dcadd8d837 feat: add Tenki sandbox provider (#17305)
### Summary

Adds a `tenki` sandbox provider that runs each agent code execution in a
disposable Tenki (https://tenki.cloud) microVM (create → exec → destroy,
no volumes or snapshots).
Registration mirrors PR #15039, configure `api_key` and `project_id` in
Admin > Sandbox Settings.

Both runtimes are covered:
- Python: `agent/sandbox/providers/tenki.py` (structured results +
artifact collection).
- Go: `internal/agent/sandbox/tenki.go`, mirroring the e2b provider and
wired into the provider manager.

`tenki-sandbox` is an optional dependency (it requires `protobuf>=6.31`,
which differs from RAGFlow's pinned gRPC stack), lazily imported with a
clear error when missing; installation is documented in the sandbox
quickstart.

Unit tests cover execution, structured results, artifacts
(symlink/size/extension limits), non-zero exit, timeout, error mapping,
and idempotent destroy.

---------

Co-authored-by: yiming.wang <yiming.wang@luxor.com>
2026-07-28 19:24:39 +08:00

312 lines
10 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// tenki.go is the Go port of
// `agent/sandbox/providers/tenki.py::TenkiProvider`. It runs each
// CodeExec call in a disposable Tenki microVM: CreateInstance
// provisions a fresh sandbox, ExecuteCode runs `python3 -c <wrapped>`
// or `node -e <wrapped>` inside it, and DestroyInstance terminates it.
// The provider uses only Tenki's create/exec/destroy operations; it
// does not use volumes or snapshots.
//
// The code-wrapping protocol is shared with SelfManaged, Aliyun and
// e2b, so the `__RAGFLOW_RESULT__:` marker extraction works uniformly.
// Like the Go e2b provider, this port does not collect run artifacts
// (artifact collection lives only in the local/ssh providers).
//
// SDK: github.com/LuxorLabs/tenki-sdk-go/sandbox (MIT). The auth
// token and API URL are read from env by Initialize; image and
// tunables come from the admin-panel config map. Sandbox scope is
// inferred from the API key by the SDK.
package sandbox
import (
"context"
"errors"
"fmt"
"ragflow/internal/common"
"sync"
"time"
tenkisdk "github.com/LuxorLabs/tenki-sdk-go/sandbox"
)
// tenkiDefaultSandboxTimeout bounds a single CodeExec call. A fresh
// sandbox is provisioned per CreateInstance and destroyed on
// DestroyInstance, so the lifetime need only outlast one execution.
const tenkiDefaultSandboxTimeout = 5 * time.Minute
// TenkiProvider is the Go port of the Python TenkiProvider.
type TenkiProvider struct {
client *tenkisdk.Client
apiKey string
apiURL string
image string
allowOutbound bool
sandboxTimeout time.Duration
mu sync.Mutex
initialized bool
}
// newTenkiProviderFromEnv reads TENKI_* env vars and returns a
// provider ready for Initialize.
func newTenkiProviderFromEnv() *TenkiProvider {
return newTenkiProviderFromConfig(tenkiConfigFromEnv())
}
// tenkiConfigFromEnv builds a config map from the TENKI_* env vars,
// mirroring the admin-panel settings JSON shape.
func tenkiConfigFromEnv() map[string]any {
return map[string]any{
"API_KEY": common.GetEnv(common.EnvTenkiApiKey),
"API_URL": common.GetEnv(common.EnvTenkiAPIURL),
"IMAGE": common.GetEnv(common.EnvTenkiImage),
"TIMEOUT": common.GetEnv(common.EnvTenkiTimeout),
"ALLOW_OUTBOUND": common.GetEnv(common.EnvTenkiAllowOutbound),
}
}
// newTenkiProviderFromConfig builds the provider from a JSON config
// map (admin-panel settings or the env-backed map above).
func newTenkiProviderFromConfig(cfg map[string]any) *TenkiProvider {
p := &TenkiProvider{
apiKey: configString(cfg, "API_KEY"),
apiURL: configString(cfg, "API_URL"),
image: configString(cfg, "IMAGE"),
// Outbound network is opt-in: sandboxed code has no egress
// unless ALLOW_OUTBOUND is explicitly "true". This matches
// the self_managed sandbox, which treats network access as an
// unauthorized-access event by default.
allowOutbound: configString(cfg, "ALLOW_OUTBOUND") == "true",
}
timeoutSec := configInt(cfg, "TIMEOUT", int(tenkiDefaultSandboxTimeout.Seconds()))
if timeoutSec > 0 {
p.sandboxTimeout = time.Duration(timeoutSec) * time.Second
} else {
p.sandboxTimeout = tenkiDefaultSandboxTimeout
}
return p
}
// ProviderType returns ProviderTenki.
func (p *TenkiProvider) ProviderType() ProviderType { return ProviderTenki }
// Initialize builds the Tenki SDK client. The auth token comes from
// the admin config or TENKI_API_KEY; we check explicitly so the
// manager does not register a broken provider.
func (p *TenkiProvider) Initialize(ctx context.Context) error {
apiKey := p.apiKey
if apiKey == "" {
apiKey = common.GetEnv(common.EnvTenkiApiKey)
}
if apiKey == "" {
return errors.New("tenki: API key is required (set it in Admin > Sandbox Settings or TENKI_API_KEY)")
}
apiURL := p.apiURL
if apiURL == "" {
apiURL = common.GetEnv(common.EnvTenkiAPIURL)
}
opts := []tenkisdk.Option{tenkisdk.WithAuthToken(apiKey)}
if apiURL != "" {
opts = append(opts, tenkisdk.WithBaseURL(apiURL))
}
c, err := tenkisdk.New(opts...)
if err != nil {
return fmt.Errorf("tenki: build client: %w", err)
}
p.client = c
p.mu.Lock()
p.initialized = true
p.mu.Unlock()
return nil
}
// SupportedLanguages returns the languages the default Tenki image can
// run. The default image ships with python3 and node.
func (p *TenkiProvider) SupportedLanguages() []string {
return []string{"python", "javascript"}
}
// CreateInstance provisions a fresh Tenki sandbox. As with the e2b
// provider, the template argument is treated as the language hint; the
// actual base image comes from the configured image (empty = Tenki's
// default image). InstanceID is the Tenki session id.
func (p *TenkiProvider) CreateInstance(ctx context.Context, template string) (*SandboxInstance, error) {
if !p.isInitialized() {
return nil, fmt.Errorf("tenki: provider not initialized")
}
lang := normalizeLanguage(template)
if lang == "" {
return nil, fmt.Errorf("tenki: unsupported language %q", template)
}
opts := []tenkisdk.CreateOption{
tenkisdk.WithAllowOutbound(p.allowOutbound),
tenkisdk.WithMaxDuration(p.sandboxTimeout),
tenkisdk.WithWaitTimeout(p.sandboxTimeout),
}
if p.image != "" {
opts = append(opts, tenkisdk.WithImage(p.image))
}
sess, err := p.client.Create(ctx, opts...)
if err != nil {
return nil, fmt.Errorf("tenki: Create: %w", err)
}
return &SandboxInstance{
InstanceID: sess.ID,
Provider: ProviderTenki,
Status: "running",
Metadata: map[string]any{
"language": lang,
"image": p.image,
"sandbox_timeout": p.sandboxTimeout.String(),
},
}, nil
}
// ExecuteCode runs the user's code inside the sandbox via
// `python3 -c <wrapped>` (Python) or `node -e <wrapped>` (JS). The
// wrapped code carries the `__RAGFLOW_RESULT__:` marker so the
// structured main() return value comes back as before.
func (p *TenkiProvider) ExecuteCode(
ctx context.Context,
inst *SandboxInstance,
code, language string,
timeoutSec int,
args map[string]any,
) (*ExecutionResult, error) {
if !p.isInitialized() {
return nil, fmt.Errorf("tenki: provider not initialized")
}
if inst == nil || inst.InstanceID == "" {
return nil, fmt.Errorf("tenki: instance id required")
}
lang := normalizeLanguage(language)
if lang == "" {
return nil, fmt.Errorf("tenki: unsupported language %q", language)
}
timeout, err := validateTimeout(timeoutSec)
if err != nil {
return nil, err
}
if timeout == 0 {
timeout = int(p.sandboxTimeout.Seconds())
}
argsJSON, err := argsToJSON(args)
if err != nil {
return nil, err
}
var wrapped, cmd string
var runArgs []string
if lang == "python" {
cmd = "python3"
wrapped = BuildPythonWrapper(code, argsJSON)
runArgs = []string{"-c", wrapped}
} else {
cmd = "node"
wrapped = BuildJavaScriptWrapper(code, argsJSON)
runArgs = []string{"-e", wrapped}
}
// Re-obtain a handle to the sandbox created in CreateInstance and
// wait for its data plane to be ready before exec.
sess, err := p.client.Session(inst.InstanceID)
if err != nil {
return nil, fmt.Errorf("tenki: Session(%s): %w", inst.InstanceID, err)
}
if err := sess.WaitReady(ctx, p.sandboxTimeout); err != nil {
return nil, fmt.Errorf("tenki: WaitReady(%s): %w", inst.InstanceID, err)
}
start := time.Now()
res, err := sess.Exec(ctx, cmd,
tenkisdk.WithArgs(runArgs...),
tenkisdk.WithTimeout(time.Duration(timeout)*time.Second),
)
if err != nil {
// A non-zero exit is reported as a *Result, not an error;
// a nil result here means a transport, timeout or session
// error that we cannot map to stdout/stderr. Do not include
// runArgs — they embed the user's code and arguments.
return nil, fmt.Errorf("tenki: exec %s: %w", cmd, err)
}
return buildTenkiExecutionResult(res, lang, start), nil
}
// buildTenkiExecutionResult maps the Tenki Result to our
// sandbox.ExecutionResult. Stdout is scanned (untrimmed) for the
// `__RAGFLOW_RESULT__:` marker so the model gets the structured
// main() return value.
func buildTenkiExecutionResult(r *tenkisdk.Result, lang string, start time.Time) *ExecutionResult {
stdout, structured := ExtractStructuredResult(string(r.Stdout))
return &ExecutionResult{
Stdout: stdout,
Stderr: string(r.Stderr),
ExitCode: int(r.ExitCode),
ExecutionTime: time.Since(start).Seconds(),
Metadata: map[string]any{
"language": lang,
"structured_result": structured,
"tenki_status": string(r.Status),
},
}
}
// DestroyInstance terminates the sandbox. A session that is already
// gone (not found, terminated, or expired past its max duration) is
// treated as success so the call is idempotent.
func (p *TenkiProvider) DestroyInstance(ctx context.Context, inst *SandboxInstance) error {
if !p.isInitialized() {
return fmt.Errorf("tenki: provider not initialized")
}
if inst == nil || inst.InstanceID == "" {
return fmt.Errorf("tenki: instance id required")
}
sess, err := p.client.Session(inst.InstanceID)
if err != nil {
return fmt.Errorf("tenki: Session(%s): %w", inst.InstanceID, err)
}
if err := sess.Close(ctx); err != nil {
if errors.Is(err, tenkisdk.ErrSessionNotFound) ||
errors.Is(err, tenkisdk.ErrSessionTerminated) ||
errors.Is(err, tenkisdk.ErrSessionExpired) {
return nil
}
return fmt.Errorf("tenki: Close(%s): %w", inst.InstanceID, err)
}
return nil
}
// HealthCheck probes the Tenki control plane. There is no dedicated
// ping endpoint, so a successful WhoAmI is our probe.
func (p *TenkiProvider) HealthCheck(ctx context.Context) error {
if !p.isInitialized() {
return errors.New("tenki: provider not initialized")
}
if _, err := p.client.WhoAmI(ctx); err != nil {
return fmt.Errorf("tenki: WhoAmI: %w", err)
}
return nil
}
func (p *TenkiProvider) isInitialized() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.initialized
}