Skip to content

Commit ca808ec

Browse files
authored
feat(artifact): add explicit folder transport (#1316)
Artifact publication and import now have durable local authority, but there was no safe way to move those normalized artifacts between independent AgentsView archives. Copying databases would duplicate origin authority, while ad hoc folder copying could expose partial writes, accept unrelated directories, or let corrupt peer content wedge later work. This adds an explicit, opt-in folder transport through `agentsview sync --target`. Targets are positively marked and confined away from provider and data roots; immutable objects are verified on pull and installed atomically on push; checkpoint conflicts fail closed; deterministic corruption is quarantined; and each invocation performs bounded work. When a writable daemon owns SQLite, the CLI delegates exchange to its authenticated loopback endpoint so a second writer cannot bypass local sync authority. Ordinary AgentsView behavior is unchanged unless `--target` is supplied. The folder contains sensitive normalized artifacts, not provider-owned JSONL, and this scope deliberately excludes raw-source archival or eviction, continuous watching, hosted/service transports, and mutable user curation. The public artifact-sync guide records those trust and lifecycle boundaries, replacing internal Superpowers planning documents with operator-facing documentation. Co-authored-by: Wes McKinney <wesm@users.noreply.github.com>
1 parent deff98a commit ca808ec

44 files changed

Lines changed: 9306 additions & 4041 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/agentsview/artifact_sync.go

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
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 = newDaemonArtifactExchangeHTTPClient()
28+
29+
func newDaemonArtifactExchangeHTTPClient() *http.Client {
30+
transport := http.DefaultTransport.(*http.Transport).Clone()
31+
transport.Proxy = nil
32+
dialer := &net.Dialer{}
33+
transport.DialContext = func(
34+
ctx context.Context,
35+
network string,
36+
address string,
37+
) (net.Conn, error) {
38+
return dialLoopbackDaemon(ctx, dialer, network, address)
39+
}
40+
return &http.Client{
41+
Transport: transport,
42+
CheckRedirect: func(
43+
*http.Request,
44+
[]*http.Request,
45+
) error {
46+
return http.ErrUseLastResponse
47+
},
48+
}
49+
}
50+
51+
func dialLoopbackDaemon(
52+
ctx context.Context,
53+
dialer *net.Dialer,
54+
network string,
55+
address string,
56+
) (net.Conn, error) {
57+
host, port, err := net.SplitHostPort(address)
58+
if err != nil || !strings.EqualFold(host, "localhost") {
59+
return dialer.DialContext(ctx, network, address)
60+
}
61+
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
62+
if err != nil {
63+
return nil, err
64+
}
65+
if len(addresses) == 0 {
66+
return nil, errors.New("localhost did not resolve to a loopback address")
67+
}
68+
for _, candidate := range addresses {
69+
if !candidate.IP.IsLoopback() {
70+
return nil, errors.New("localhost resolved to a non-loopback address")
71+
}
72+
}
73+
var dialErr error
74+
for _, candidate := range addresses {
75+
connection, candidateErr := dialer.DialContext(
76+
ctx,
77+
network,
78+
net.JoinHostPort(candidate.String(), port),
79+
)
80+
if candidateErr == nil {
81+
return connection, nil
82+
}
83+
dialErr = errors.Join(dialErr, candidateErr)
84+
}
85+
return nil, dialErr
86+
}
87+
88+
func validateArtifactSyncConfig(cfg SyncConfig) error {
89+
if cfg.Target != "" && cfg.Host != "" {
90+
return fmt.Errorf("--target cannot be combined with --host")
91+
}
92+
return nil
93+
}
94+
95+
func runArtifactFolderSync(
96+
ctx context.Context,
97+
appCfg config.Config,
98+
database *db.DB,
99+
cfg SyncConfig,
100+
) (artifact.SyncResult, error) {
101+
result, err := runArtifactSyncCLI(ctx, database, artifact.SyncOptions{
102+
DataDir: appCfg.DataDir,
103+
Target: cfg.Target,
104+
ForbiddenRoots: artifactSyncForbiddenRoots(appCfg),
105+
Full: cfg.Full,
106+
})
107+
if err != nil {
108+
return result, &artifactFolderSyncError{cause: err}
109+
}
110+
return result, nil
111+
}
112+
113+
func newDaemonArtifactExchangeRunner(
114+
appCfg config.Config,
115+
database *db.DB,
116+
engine *agentsync.Engine,
117+
emitter agentsync.Emitter,
118+
) server.ArtifactExchangeRunner {
119+
return func(
120+
ctx context.Context,
121+
request server.ArtifactExchangeRequest,
122+
) (result artifact.SyncResult, err error) {
123+
work := func() error {
124+
result, err = runArtifactFolderSync(
125+
ctx,
126+
appCfg,
127+
database,
128+
SyncConfig{Target: request.Target, Full: request.Full},
129+
)
130+
return err
131+
}
132+
if engine == nil {
133+
err = work()
134+
} else {
135+
err = engine.RunExclusiveFlushed(work)
136+
}
137+
if result.ImportedSessions > 0 && emitter != nil {
138+
emitter.Emit("sessions")
139+
}
140+
return result, err
141+
}
142+
}
143+
144+
func runDaemonArtifactExchange(
145+
ctx context.Context,
146+
tr transport,
147+
authToken string,
148+
target string,
149+
full bool,
150+
) (artifact.SyncResult, error) {
151+
target, err := filepath.Abs(target)
152+
if err != nil {
153+
return artifact.SyncResult{}, &daemonArtifactExchangeError{cause: err}
154+
}
155+
baseURL, err := validatedLoopbackDaemonURL(tr.URL)
156+
if err != nil {
157+
return artifact.SyncResult{}, &daemonArtifactExchangeError{cause: err}
158+
}
159+
body, err := json.Marshal(server.ArtifactExchangeRequest{
160+
Target: target,
161+
Full: full,
162+
})
163+
if err != nil {
164+
return artifact.SyncResult{}, &daemonArtifactExchangeError{cause: err}
165+
}
166+
req, err := http.NewRequestWithContext(
167+
ctx,
168+
http.MethodPost,
169+
baseURL+"/api/v1/artifacts/exchange",
170+
strings.NewReader(string(body)),
171+
)
172+
if err != nil {
173+
return artifact.SyncResult{}, &daemonArtifactExchangeError{cause: err}
174+
}
175+
req.Header.Set("Content-Type", "application/json")
176+
req.Header.Set("Origin", baseURL)
177+
if authToken != "" {
178+
req.Header.Set("Authorization", "Bearer "+authToken)
179+
}
180+
181+
response, err := daemonArtifactExchangeHTTPClient.Do(req)
182+
if err != nil {
183+
return artifact.SyncResult{}, &daemonArtifactExchangeError{cause: err}
184+
}
185+
defer response.Body.Close()
186+
if response.StatusCode != http.StatusOK {
187+
return artifact.SyncResult{}, &daemonArtifactExchangeError{
188+
cause: fmt.Errorf("daemon returned HTTP %d", response.StatusCode),
189+
}
190+
}
191+
192+
decoder := json.NewDecoder(io.LimitReader(
193+
response.Body,
194+
daemonArtifactExchangeResponseLimit+1,
195+
))
196+
decoder.DisallowUnknownFields()
197+
var result artifact.SyncResult
198+
if err := decoder.Decode(&result); err != nil {
199+
return artifact.SyncResult{}, &daemonArtifactExchangeError{cause: err}
200+
}
201+
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
202+
if err == nil {
203+
err = errors.New("daemon returned trailing JSON")
204+
}
205+
return artifact.SyncResult{}, &daemonArtifactExchangeError{cause: err}
206+
}
207+
return result, nil
208+
}
209+
210+
func validatedLoopbackDaemonURL(rawURL string) (string, error) {
211+
parsed, err := url.Parse(rawURL)
212+
if err != nil {
213+
return "", err
214+
}
215+
if parsed.Scheme != "http" ||
216+
parsed.User != nil ||
217+
parsed.Host == "" ||
218+
(parsed.Path != "" && parsed.Path != "/") ||
219+
parsed.RawQuery != "" ||
220+
parsed.Fragment != "" {
221+
return "", errors.New("unsafe daemon endpoint")
222+
}
223+
hostname := parsed.Hostname()
224+
ip := net.ParseIP(hostname)
225+
if !strings.EqualFold(hostname, "localhost") &&
226+
(ip == nil || !ip.IsLoopback()) {
227+
return "", errors.New("daemon endpoint is not loopback")
228+
}
229+
return strings.TrimSuffix(parsed.String(), "/"), nil
230+
}
231+
232+
func runLocalAndArtifactFolderSync(
233+
ctx context.Context,
234+
appCfg config.Config,
235+
database *db.DB,
236+
cfg SyncConfig,
237+
) (artifact.SyncResult, error) {
238+
if _, _, err := runLocalSyncResult(
239+
ctx,
240+
appCfg,
241+
database,
242+
cfg.Full,
243+
); err != nil {
244+
return artifact.SyncResult{}, err
245+
}
246+
return runArtifactFolderSync(ctx, appCfg, database, cfg)
247+
}
248+
249+
func artifactSyncForbiddenRoots(appCfg config.Config) []string {
250+
roots := make([]string, 0, 1+len(appCfg.AgentDirs))
251+
seen := make(map[string]struct{}, 1+len(appCfg.AgentDirs))
252+
appendRoot := func(root string) {
253+
if strings.TrimSpace(root) == "" {
254+
return
255+
}
256+
if isRemoteSourceRoot(root) {
257+
return
258+
}
259+
root = filepath.Clean(root)
260+
if _, ok := seen[root]; ok {
261+
return
262+
}
263+
seen[root] = struct{}{}
264+
roots = append(roots, root)
265+
}
266+
appendRoot(appCfg.DataDir)
267+
for _, def := range parser.Registry {
268+
for _, root := range appCfg.AgentDirs[def.Type] {
269+
appendRoot(root)
270+
}
271+
}
272+
return roots
273+
}
274+
275+
type artifactFolderSyncError struct {
276+
cause error
277+
}
278+
279+
type daemonArtifactExchangeError struct {
280+
cause error
281+
}
282+
283+
func (e *daemonArtifactExchangeError) Error() string {
284+
return "daemon artifact exchange failed"
285+
}
286+
287+
func (e *daemonArtifactExchangeError) Unwrap() error {
288+
if e == nil {
289+
return nil
290+
}
291+
return e.cause
292+
}
293+
294+
func (e *artifactFolderSyncError) Error() string {
295+
return "artifact folder sync failed"
296+
}
297+
298+
func (e *artifactFolderSyncError) Unwrap() error {
299+
if e == nil {
300+
return nil
301+
}
302+
return e.cause
303+
}
304+
305+
func printArtifactSyncSummary(w io.Writer, result artifact.SyncResult) {
306+
fmt.Fprintf(
307+
w,
308+
"Artifacts: exported %s; imported %s and %s; received %s; published %s",
309+
artifactSyncCount(result.ExportedSessions, "session"),
310+
artifactSyncCount(result.ImportedSessions, "session"),
311+
artifactSyncCount(result.ImportedMessages, "message"),
312+
artifactSyncCount(result.ReceivedArtifacts, "object"),
313+
artifactSyncCount(result.PublishedArtifacts, "object"),
314+
)
315+
if result.RejectedSessions > 0 {
316+
fmt.Fprintf(
317+
w,
318+
"; rejected %s",
319+
artifactSyncCount(result.RejectedSessions, "session"),
320+
)
321+
}
322+
if result.Quarantined > 0 {
323+
fmt.Fprintf(
324+
w,
325+
"; quarantined %s",
326+
artifactSyncCount(result.Quarantined, "object"),
327+
)
328+
}
329+
fmt.Fprintln(w)
330+
if result.More {
331+
fmt.Fprintln(
332+
w,
333+
"Artifact work remains; run the sync command again.",
334+
)
335+
}
336+
}
337+
338+
func artifactSyncCount(count int, noun string) string {
339+
if count == 1 {
340+
return fmt.Sprintf("%d %s", count, noun)
341+
}
342+
return fmt.Sprintf("%d %ss", count, noun)
343+
}

0 commit comments

Comments
 (0)