Skip to content

Commit d9af33a

Browse files
committed
feat(artifact): confine marked folder targets
Artifact folder exchange needs a fail-closed namespace and authority boundary before normalized sessions can move safely through user-selected shared storage. The transport remains explicit and opt-in so ordinary provider sync and provider-owned JSONL behavior are unchanged. - Pull verified folder artifacts - Publish folder artifacts atomically - Coordinate bounded one-shot folder sync - Add the explicit artifact folder sync CLI - Harden folder exchange authority - Delegate exchange to the writable daemon - Prove round-trip convergence and replay safety - Close process, path, and replacement race gaps - Remove internal planning documents and align cancellation coverage with absolute target validation
1 parent 277f126 commit d9af33a

30 files changed

Lines changed: 4938 additions & 4035 deletions

cmd/agentsview/artifact_sync.go

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"net"
10+
"net/http"
11+
"net/url"
12+
"path/filepath"
13+
"strings"
14+
15+
"go.kenn.io/agentsview/internal/artifact"
16+
"go.kenn.io/agentsview/internal/config"
17+
"go.kenn.io/agentsview/internal/db"
18+
"go.kenn.io/agentsview/internal/parser"
19+
"go.kenn.io/agentsview/internal/server"
20+
agentsync "go.kenn.io/agentsview/internal/sync"
21+
)
22+
23+
var runArtifactSyncCLI = artifact.Sync
24+
25+
const daemonArtifactExchangeResponseLimit = 1 << 20
26+
27+
var daemonArtifactExchangeHTTPClient = &http.Client{
28+
CheckRedirect: func(
29+
*http.Request,
30+
[]*http.Request,
31+
) error {
32+
return http.ErrUseLastResponse
33+
},
34+
}
35+
36+
func validateArtifactSyncConfig(cfg SyncConfig) error {
37+
if cfg.Target != "" && cfg.Host != "" {
38+
return fmt.Errorf("--target cannot be combined with --host")
39+
}
40+
return nil
41+
}
42+
43+
func runArtifactFolderSync(
44+
ctx context.Context,
45+
appCfg config.Config,
46+
database *db.DB,
47+
cfg SyncConfig,
48+
) (artifact.SyncResult, error) {
49+
result, err := runArtifactSyncCLI(ctx, database, artifact.SyncOptions{
50+
DataDir: appCfg.DataDir,
51+
Target: cfg.Target,
52+
ForbiddenRoots: artifactSyncForbiddenRoots(appCfg),
53+
Full: cfg.Full,
54+
})
55+
if err != nil {
56+
return result, &artifactFolderSyncError{cause: err}
57+
}
58+
return result, nil
59+
}
60+
61+
func newDaemonArtifactExchangeRunner(
62+
appCfg config.Config,
63+
database *db.DB,
64+
engine *agentsync.Engine,
65+
) server.ArtifactExchangeRunner {
66+
return func(
67+
ctx context.Context,
68+
request server.ArtifactExchangeRequest,
69+
) (result artifact.SyncResult, err error) {
70+
work := func() error {
71+
result, err = runArtifactFolderSync(
72+
ctx,
73+
appCfg,
74+
database,
75+
SyncConfig{Target: request.Target, Full: request.Full},
76+
)
77+
return err
78+
}
79+
if engine == nil {
80+
err = work()
81+
} else {
82+
err = engine.RunExclusiveFlushed(work)
83+
}
84+
return result, err
85+
}
86+
}
87+
88+
func runDaemonArtifactExchange(
89+
ctx context.Context,
90+
tr transport,
91+
authToken string,
92+
target string,
93+
full bool,
94+
) (artifact.SyncResult, error) {
95+
target, err := filepath.Abs(target)
96+
if err != nil {
97+
return artifact.SyncResult{}, &daemonArtifactExchangeError{cause: err}
98+
}
99+
baseURL, err := validatedLoopbackDaemonURL(tr.URL)
100+
if err != nil {
101+
return artifact.SyncResult{}, &daemonArtifactExchangeError{cause: err}
102+
}
103+
body, err := json.Marshal(server.ArtifactExchangeRequest{
104+
Target: target,
105+
Full: full,
106+
})
107+
if err != nil {
108+
return artifact.SyncResult{}, &daemonArtifactExchangeError{cause: err}
109+
}
110+
req, err := http.NewRequestWithContext(
111+
ctx,
112+
http.MethodPost,
113+
baseURL+"/api/v1/artifacts/exchange",
114+
strings.NewReader(string(body)),
115+
)
116+
if err != nil {
117+
return artifact.SyncResult{}, &daemonArtifactExchangeError{cause: err}
118+
}
119+
req.Header.Set("Content-Type", "application/json")
120+
req.Header.Set("Origin", baseURL)
121+
if authToken != "" {
122+
req.Header.Set("Authorization", "Bearer "+authToken)
123+
}
124+
125+
response, err := daemonArtifactExchangeHTTPClient.Do(req)
126+
if err != nil {
127+
return artifact.SyncResult{}, &daemonArtifactExchangeError{cause: err}
128+
}
129+
defer response.Body.Close()
130+
if response.StatusCode != http.StatusOK {
131+
return artifact.SyncResult{}, &daemonArtifactExchangeError{
132+
cause: fmt.Errorf("daemon returned HTTP %d", response.StatusCode),
133+
}
134+
}
135+
136+
decoder := json.NewDecoder(io.LimitReader(
137+
response.Body,
138+
daemonArtifactExchangeResponseLimit+1,
139+
))
140+
decoder.DisallowUnknownFields()
141+
var result artifact.SyncResult
142+
if err := decoder.Decode(&result); err != nil {
143+
return artifact.SyncResult{}, &daemonArtifactExchangeError{cause: err}
144+
}
145+
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
146+
if err == nil {
147+
err = errors.New("daemon returned trailing JSON")
148+
}
149+
return artifact.SyncResult{}, &daemonArtifactExchangeError{cause: err}
150+
}
151+
return result, nil
152+
}
153+
154+
func validatedLoopbackDaemonURL(rawURL string) (string, error) {
155+
parsed, err := url.Parse(rawURL)
156+
if err != nil {
157+
return "", err
158+
}
159+
if parsed.Scheme != "http" ||
160+
parsed.User != nil ||
161+
parsed.Host == "" ||
162+
(parsed.Path != "" && parsed.Path != "/") ||
163+
parsed.RawQuery != "" ||
164+
parsed.Fragment != "" {
165+
return "", errors.New("unsafe daemon endpoint")
166+
}
167+
ip := net.ParseIP(parsed.Hostname())
168+
if strings.EqualFold(parsed.Hostname(), "localhost") {
169+
host := "127.0.0.1"
170+
if parsed.Port() != "" {
171+
host = net.JoinHostPort(host, parsed.Port())
172+
}
173+
parsed.Host = host
174+
ip = net.ParseIP("127.0.0.1")
175+
}
176+
if ip == nil || !ip.IsLoopback() {
177+
return "", errors.New("daemon endpoint is not loopback")
178+
}
179+
return strings.TrimSuffix(parsed.String(), "/"), nil
180+
}
181+
182+
func runLocalAndArtifactFolderSync(
183+
ctx context.Context,
184+
appCfg config.Config,
185+
database *db.DB,
186+
cfg SyncConfig,
187+
) (artifact.SyncResult, error) {
188+
if _, _, err := runLocalSyncResult(
189+
ctx,
190+
appCfg,
191+
database,
192+
cfg.Full,
193+
); err != nil {
194+
return artifact.SyncResult{}, err
195+
}
196+
return runArtifactFolderSync(ctx, appCfg, database, cfg)
197+
}
198+
199+
func artifactSyncForbiddenRoots(appCfg config.Config) []string {
200+
roots := make([]string, 0, 1+len(appCfg.AgentDirs))
201+
seen := make(map[string]struct{}, 1+len(appCfg.AgentDirs))
202+
appendRoot := func(root string) {
203+
if strings.TrimSpace(root) == "" {
204+
return
205+
}
206+
root = filepath.Clean(root)
207+
if _, ok := seen[root]; ok {
208+
return
209+
}
210+
seen[root] = struct{}{}
211+
roots = append(roots, root)
212+
}
213+
appendRoot(appCfg.DataDir)
214+
for _, def := range parser.Registry {
215+
for _, root := range appCfg.AgentDirs[def.Type] {
216+
appendRoot(root)
217+
}
218+
}
219+
return roots
220+
}
221+
222+
type artifactFolderSyncError struct {
223+
cause error
224+
}
225+
226+
type daemonArtifactExchangeError struct {
227+
cause error
228+
}
229+
230+
func (e *daemonArtifactExchangeError) Error() string {
231+
return "daemon artifact exchange failed"
232+
}
233+
234+
func (e *daemonArtifactExchangeError) Unwrap() error {
235+
if e == nil {
236+
return nil
237+
}
238+
return e.cause
239+
}
240+
241+
func (e *artifactFolderSyncError) Error() string {
242+
return "artifact folder sync failed"
243+
}
244+
245+
func (e *artifactFolderSyncError) Unwrap() error {
246+
if e == nil {
247+
return nil
248+
}
249+
return e.cause
250+
}
251+
252+
func printArtifactSyncSummary(w io.Writer, result artifact.SyncResult) {
253+
fmt.Fprintf(
254+
w,
255+
"Artifacts: exported %s; imported %s and %s; received %s; published %s",
256+
artifactSyncCount(result.ExportedSessions, "session"),
257+
artifactSyncCount(result.ImportedSessions, "session"),
258+
artifactSyncCount(result.ImportedMessages, "message"),
259+
artifactSyncCount(result.ReceivedArtifacts, "object"),
260+
artifactSyncCount(result.PublishedArtifacts, "object"),
261+
)
262+
if result.RejectedSessions > 0 {
263+
fmt.Fprintf(
264+
w,
265+
"; rejected %s",
266+
artifactSyncCount(result.RejectedSessions, "session"),
267+
)
268+
}
269+
if result.Quarantined > 0 {
270+
fmt.Fprintf(
271+
w,
272+
"; quarantined %s",
273+
artifactSyncCount(result.Quarantined, "object"),
274+
)
275+
}
276+
fmt.Fprintln(w)
277+
if result.More {
278+
fmt.Fprintln(
279+
w,
280+
"Artifact work remains; run the sync command again.",
281+
)
282+
}
283+
}
284+
285+
func artifactSyncCount(count int, noun string) string {
286+
if count == 1 {
287+
return fmt.Sprintf("%d %s", count, noun)
288+
}
289+
return fmt.Sprintf("%d %ss", count, noun)
290+
}

0 commit comments

Comments
 (0)