Files
knowledge-governance/internal/workflows/artifactfetch/handler.go
T
姚章浩 d29e04e647 fix(artifact): 白名单环境漏传 dws 变量导致钉钉取件整体失败
端到端验收实测(2026-10-10):标注后取件以 artifact_fetch_rejected: credentials 终态失败。
根因:artifactfetch runner 给子进程的是白名单环境(防凭据外泄),但没有透传 dws 取件器
必需的 ARTIFACT_DINGTALK_FETCHER/DWS_BIN/DWS_CONFIG_DIR/DWS_HOME/DWS_DISABLE_KEYCHAIN
→ dws 找不到配置目录与文件型 keychain($HOME/.local/share/dws-cli 落空)→ 报未登录 →
映射成 credentials 终态(不可重试)→ 且不会回落老路径。脚本单跑正常,因为绕过了白名单。

- handler.go: childEnv() 显式透传 dws 变量(仅非空值);失败时把 stderr 尾部脱敏后带进错误,
  否则只能看到裸 code 无法定位(本次排查就卡在这)
- 新增 3 个回归测试:环境透传 / 空值不透传 / stderr 上报与敏感内容抑制
- 实测:修复后重新入队 → artifact.fetch completed,产物 sn-753f….docx 4799B(adoc→docx 导出),
  content_objects 记为 docx/verified
2026-10-10 09:57:41 +08:00

294 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package artifactfetch turns a saved policy intent into verified bytes: it
// shells out to scripts/artifact_fetch.py (which already holds every source
// adapter: AList direct download, PP ledger, DingTalk export-tree/live export)
// and records the verified content against the pending source item version.
package artifactfetch
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"knowledge-governance/internal/jobs"
)
type Request struct {
ConnectionID string `json:"source_connection_id"`
NodeID string `json:"node_id"`
VersionID string `json:"version_id"`
}
type Result struct {
OK bool `json:"ok"`
SpecID string `json:"spec_id"`
NodeID string `json:"node_id"`
ConnectionID string `json:"connection_id"`
SHA256 string `json:"sha256"`
SizeBytes int64 `json:"size_bytes"`
SpoolRef string `json:"spool_ref"`
ValidatedFormat string `json:"validated_format"`
MatchedLocal bool `json:"matched_local"`
Missing bool `json:"missing"`
Retryable bool `json:"retryable"`
Code string `json:"code"`
Error string `json:"error"`
// StderrTail 是脚本 stderr 的(脱敏、截断后的)尾部,只在失败时用于报告原因;
// json:"-" 保证它不参与结果 JSON 契约(解码侧是 DisallowUnknownFields)。
StderrTail string `json:"-"`
}
type Manifest struct {
Request
SourceType string
SourceName string
RelativePath string
Name string
SizeBytes *int64
ModifiedAt *time.Time
LocatorHash string
}
type Store interface {
LoadArtifactFetchManifest(context.Context, string, string) (Manifest, error)
MarkArtifactFetched(context.Context, string, string, int64, string, string, bool, time.Time) error
MarkArtifactFetchMissing(context.Context, string, string, time.Time) error
MarkArtifactFetchFailed(context.Context, string, string, string, time.Time) error
}
type Runner interface {
Run(context.Context, Manifest) (Result, error)
}
type Config struct {
Store Store
Runner Runner
Now func() time.Time
}
type Handler struct {
store Store
runner Runner
now func() time.Time
}
func NewHandler(config Config) (*Handler, error) {
if config.Store == nil || config.Runner == nil {
return nil, errors.New("artifact fetch dependencies are required")
}
if config.Now == nil {
config.Now = time.Now
}
return &Handler{store: config.Store, runner: config.Runner, now: config.Now}, nil
}
func (handler *Handler) Handle(ctx context.Context, job jobs.Job) error {
var request Request
decoder := json.NewDecoder(bytes.NewReader(job.Payload))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil ||
request.ConnectionID == "" || request.NodeID == "" || request.VersionID == "" {
return jobs.Permanent(jobs.ErrCodeArtifactInvalid, errors.New("artifact_fetch_invalid_payload"))
}
manifest, err := handler.store.LoadArtifactFetchManifest(ctx, request.ConnectionID, request.NodeID)
if err != nil {
if errors.Is(err, ErrManifestFiltered) {
return nil
}
if errors.Is(err, ErrManifestGone) {
return jobs.Permanent(jobs.ErrCodeStateConflict, err)
}
return err
}
result, err := handler.runner.Run(ctx, manifest)
now := handler.now()
if err != nil {
_ = handler.store.MarkArtifactFetchFailed(ctx, request.ConnectionID, request.NodeID, err.Error(), now)
return err
}
switch {
case result.Missing:
if err := handler.store.MarkArtifactFetchMissing(ctx, request.ConnectionID, request.NodeID, now); err != nil {
return err
}
return jobs.Permanent(jobs.ErrCodeArtifactInvalid, errors.New("artifact_fetch_missing_source: "+safeMessage(result.Error)+stderrDetail(result.StderrTail)))
case !result.OK:
_ = handler.store.MarkArtifactFetchFailed(ctx, request.ConnectionID, request.NodeID, safeMessage(result.Error), now)
if result.Retryable {
return errors.New("artifact_fetch_retryable: " + safeMessage(result.Code) + stderrDetail(result.StderrTail))
}
return jobs.Permanent(jobs.ErrCodeArtifactInvalid, errors.New("artifact_fetch_rejected: "+safeMessage(result.Code)+stderrDetail(result.StderrTail)))
}
if result.SHA256 == "" || result.SpoolRef == "" || result.SizeBytes <= 0 || result.NodeID != request.NodeID || result.ValidatedFormat == "" {
_ = handler.store.MarkArtifactFetchFailed(ctx, request.ConnectionID, request.NodeID, "artifact fetch returned an inconsistent result", now)
return errors.New("artifact_fetch_invalid_output")
}
if err := handler.store.MarkArtifactFetched(ctx, request.VersionID, strings.ToLower(result.SHA256),
result.SizeBytes, result.SpoolRef, result.ValidatedFormat, result.MatchedLocal, now); err != nil {
return err
}
return nil
}
func safeMessage(value string) string {
value = strings.ReplaceAll(strings.TrimSpace(value), "\n", " ")
lower := strings.ToLower(value)
for _, forbidden := range []string{"token", "secret", "password", "authorization", "bearer", "cookie", "/run/source-secrets", "http://", "https://"} {
if strings.Contains(lower, forbidden) {
return "source reported an error (details suppressed)"
}
}
if len([]rune(value)) > 200 {
value = string([]rune(value)[:200])
}
return value
}
// CommandRunner executes the fetch script once and parses its single JSON line.
type CommandRunner struct {
PythonPath string
ScriptPath string
SpoolRoot string
SpoolPrefix string
MaxBytes int64
}
func NewCommandRunner(pythonPath, scriptPath, spoolRoot, spoolPrefix string, maxBytes int64) (*CommandRunner, error) {
if !filepath.IsAbs(pythonPath) || !filepath.IsAbs(scriptPath) || !filepath.IsAbs(spoolRoot) || maxBytes <= 0 {
return nil, errors.New("artifact fetch command paths must be absolute")
}
if strings.Contains(spoolPrefix, "..") || strings.Contains(spoolPrefix, "\\") {
return nil, errors.New("artifact fetch spool prefix is invalid")
}
spoolPrefix = strings.Trim(spoolPrefix, "/")
if spoolPrefix != "" {
spoolPrefix += "/"
}
return &CommandRunner{PythonPath: pythonPath, ScriptPath: scriptPath, SpoolRoot: spoolRoot, SpoolPrefix: spoolPrefix, MaxBytes: maxBytes}, nil
}
// dwsEnvNames 是 dws 取件器必需的运行环境。子进程用的是白名单环境(为了不把任何
// 凭据带进子进程),因此这几项必须显式透传:漏传时 dws 会找不到配置目录与文件型
// keychain($HOME/.local/share/dws-cli 落空),报"未登录"并被映射成 credentials 终态
// ——钉钉取件在真实链路上会整体失败(2026-10-10 端到端验收实测;脚本单跑正常,
// 因为那只绕过了这个白名单)。
var dwsEnvNames = []string{
"ARTIFACT_DINGTALK_FETCHER", "DWS_BIN", "DWS_CONFIG_DIR", "DWS_HOME", "DWS_DISABLE_KEYCHAIN",
}
func forwardedDWSEnv() []string {
forwarded := make([]string, 0, len(dwsEnvNames))
for _, name := range dwsEnvNames {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
forwarded = append(forwarded, name+"="+value)
}
}
return forwarded
}
// childEnv 是取件子进程的完整环境:固定白名单 + dws 透传项。
func (runner *CommandRunner) childEnv() []string {
env := []string{
"PATH=/usr/bin:/bin",
"PYTHONUNBUFFERED=1",
"INGESTION_SPOOL_ROOT=" + runner.SpoolRoot,
"SPOOL_REF_PREFIX=" + runner.SpoolPrefix,
"ARTIFACT_FETCH_MAX_BYTES=" + strconv.FormatInt(runner.MaxBytes, 10),
"TZ=UTC",
}
return append(env, forwardedDWSEnv()...)
}
// stderrDetail 把脚本 stderr 尾部附到错误里便于定位(先脱敏,再截断)。
func stderrDetail(tail string) string {
detail := safeMessage(tail)
if detail == "" {
return ""
}
return " [" + detail + "]"
}
func (runner *CommandRunner) Run(ctx context.Context, manifest Manifest) (Result, error) {
manifestJSON, err := json.Marshal(map[string]any{
"spec_id": manifest.VersionID, "node_id": manifest.NodeID, "connection_id": manifest.ConnectionID,
"source_type": manifest.SourceType, "source_name": manifest.SourceName,
"relative_path": manifest.RelativePath, "name": manifest.Name,
"size_bytes": manifest.SizeBytes, "locator_hash": manifest.LocatorHash,
})
if err != nil {
return Result{}, errors.New("artifact_fetch_manifest_failed")
}
command := exec.CommandContext(ctx, runner.PythonPath, runner.ScriptPath, string(manifestJSON))
// The literal guard keeps secrets out of the child environment: the script
// only ever reads pre-mounted files. dws 取件器的运行环境在白名单里显式透传(见 childEnv)。
command.Env = runner.childEnv()
stdout, err := command.StdoutPipe()
if err != nil {
return Result{}, errors.New("artifact_fetch_start_failed")
}
stderr := &limitedWriter{buffer: bytes.NewBuffer(nil), limit: 4096}
command.Stderr = stderr
if err := command.Start(); err != nil {
return Result{}, errors.New("artifact_fetch_start_failed")
}
var result Result
decoded := false
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 4096), 1<<20)
for scanner.Scan() {
line := bytes.TrimSpace(scanner.Bytes())
if len(line) == 0 || !bytes.HasPrefix(line, []byte("{")) {
continue
}
decoder := json.NewDecoder(bytes.NewReader(line))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&result); err != nil {
_ = command.Process.Kill()
_ = command.Wait()
return Result{}, errors.New("artifact_fetch_invalid_output")
}
decoded = true
break
}
waitErr := command.Wait()
if !decoded {
tail := stderr.buffer.String()
if waitErr != nil {
return Result{OK: false, StderrTail: tail, Error: "artifact fetch exited without a result"},
errors.New("artifact_fetch_failed" + stderrDetail(tail))
}
return Result{}, errors.New("artifact_fetch_no_output" + stderrDetail(tail))
}
result.StderrTail = stderr.buffer.String()
return result, nil
}
var ErrManifestGone = errors.New("artifact fetch manifest is gone")
var ErrManifestFiltered = errors.New("artifact fetch excluded by filename filter")
// limitedWriter captures at most limit bytes of script stderr; full output is
// never retained (it could echo source details we do not want in failures).
type limitedWriter struct {
buffer *bytes.Buffer
limit int
}
func (writer *limitedWriter) Write(payload []byte) (int, error) {
room := writer.limit - writer.buffer.Len()
if room > 0 {
if len(payload) > room {
writer.buffer.Write(payload[:room])
return len(payload), nil
}
writer.buffer.Write(payload)
}
return len(payload), nil
}