Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,11 @@ jobs:
TEST_SSH_USER: testuser
TEST_SSH_KEY: ${{ github.workspace }}/testdata/ssh/test_key

- name: Run MinIO object-store integration test
run: make test-minio
env:
CGO_ENABLED: "1"

e2e:
runs-on: ubuntu-latest
steps:
Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ Instructions for autonomous coding agents working in this repository.
details, and absolute user paths out of code, tests, fixtures, docs, commit
messages, and pull request text. Run the private-data scrub before
publishing.
- Keep agent-authored working specs and implementation plans under the ignored
`.superpowers/` directory. Never add them to tracked `docs/superpowers/` or
ship them in pull requests.
- Keep pull request titles and descriptions synchronized with the current diff.
- Do not post pull request or issue comments unless explicitly requested.

Expand Down
13 changes: 10 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ AIR_BIN := $(shell if command -v air >/dev/null 2>&1; then command -v air; \
elif [ -x "$(GOPATH_FIRST)/bin/air" ]; then printf "%s" "$(GOPATH_FIRST)/bin/air"; \
fi)

.PHONY: build build-release install frontend frontend-dev dev check-air air-install desktop-dev desktop-build desktop-macos-app desktop-macos-dmg desktop-windows-installer desktop-linux-appimage desktop-app docs-install docs-build docs-serve docs-check docs-screenshots docs-assets-branch docs-generated-assets-branch docs-deploy-staging docs-deploy test test-short test-evalingest bench-backends bench-gate bench-gate-config test-postgres test-postgres-ci test-s3 postgres-up postgres-down test-ssh test-ssh-ci ssh-up ssh-down e2e e2e-duckdb vet lint lint-ci lint-golangci lint-golangci-ci nilaway nilaway-golangci-build lint-tools tidy clean release release-darwin-arm64 release-darwin-amd64 release-linux-amd64 install-hooks ensure-embed-dir pricing-snapshot sqlite-vec-header dev-snapshot help
.PHONY: build build-release install frontend frontend-dev dev check-air air-install desktop-dev desktop-build desktop-macos-app desktop-macos-dmg desktop-windows-installer desktop-linux-appimage desktop-app docs-install docs-build docs-serve docs-check docs-screenshots docs-assets-branch docs-generated-assets-branch docs-deploy-staging docs-deploy test test-short test-evalingest bench-backends bench-gate bench-gate-config test-postgres test-postgres-ci test-s3 test-minio postgres-up postgres-down test-ssh test-ssh-ci ssh-up ssh-down e2e e2e-duckdb vet lint lint-ci lint-golangci lint-golangci-ci nilaway nilaway-golangci-build lint-tools tidy clean release release-darwin-arm64 release-darwin-amd64 release-linux-amd64 install-hooks ensure-embed-dir pricing-snapshot sqlite-vec-header dev-snapshot help

# Ensure go:embed has at least one file (no-op if frontend is built)
ensure-embed-dir:
Expand Down Expand Up @@ -351,6 +351,11 @@ test-postgres-ci: pricing-snapshot ensure-embed-dir
test-s3: pricing-snapshot ensure-embed-dir
CGO_ENABLED=1 go test -tags "fts5,s3test" -v ./internal/sync/... -run TestS3 -count=1

# MinIO/S3 object-store integration test. testcontainers starts and tears down
# the MinIO container automatically, so this just needs a working Docker daemon.
test-minio: ensure-embed-dir
CGO_ENABLED=1 go test -tags "fts5,miniotest" -v ./internal/artifact/... -run MinIO -count=1

# Start test SSH container
ssh-up:
docker compose -f docker-compose.test.yml up -d --build --wait sshd
Expand All @@ -371,8 +376,9 @@ test-ssh: pricing-snapshot ensure-embed-dir ssh-up
test-ssh-ci: pricing-snapshot ensure-embed-dir
CGO_ENABLED=1 go test -tags "fts5,sshtest" -v ./internal/ssh/... -count=1

# Run Playwright E2E tests
e2e:
# Run artifact sync and Playwright E2E tests
e2e: ensure-embed-dir
CGO_ENABLED=1 go test -tags "fts5,e2e" ./internal/e2e -v -count=1
cd frontend && npx playwright test

# Run focused Playwright smoke tests against duckdb serve.
Expand Down Expand Up @@ -547,6 +553,7 @@ help:
@echo " bench-gate - Run the hot-path benchmarks CI gates PRs on"
@echo " test-postgres - Run PostgreSQL integration tests"
@echo " test-s3 - Run S3 discovery integration tests (Docker)"
@echo " test-minio - Run MinIO/S3 object-store integration test (needs Docker)"
@echo " postgres-up - Start test PostgreSQL container"
@echo " postgres-down - Stop test PostgreSQL container"
@echo " test-ssh - Run SSH integration tests"
Expand Down
128 changes: 75 additions & 53 deletions README.md

Large diffs are not rendered by default.

45 changes: 26 additions & 19 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ risk via documented flags and config.
attacker.
- Parser crashes, excessive resource use, or active-content injection triggered
by content inside supported session files. Session files often contain
web/tool output that agentsview did not author, and defensive parsing of that
content is a security-relevant concern.
web/tool output that agentsview did not author, and defensive parsing of
that content is a security-relevant concern.
- Inadvertent exposure of secrets that appear in transcripts. agentsview ships a
best-effort secret detector and redacts findings in the UI and CLI by default
(see [Secrets subsystem](#secrets-subsystem)).
best-effort secret detector and redacts findings in the UI and CLI by
default (see [Secrets subsystem](#secrets-subsystem)).

### Explicitly out of scope (today)

Expand Down Expand Up @@ -63,15 +63,16 @@ risk via documented flags and config.
| HTTP server → caller | Loopback-trusted; bearer-gated for `/api/` when `--require-auth` | Static assets are not gated. |
| Browser → HTTP server | Host-header allowlist + CORS + CSP + X-Frame-Options enforced | DNS-rebinding, framing, and cross-origin defenses. |
| agentsview → PostgreSQL (pg push) | TLS required for non-loopback hosts | Plaintext rejected unless `allow_insecure = true` is set explicitly. |
| agentsview → artifact HTTP peer | HTTPS required for non-loopback peers | Plaintext rejected unless `sync --allow-insecure` is explicit. |
| agentsview → update endpoint | One-way outbound, opt-out | Disable with `--no-update-check`. |
| agentsview → LiteLLM pricing | One-way outbound, on-demand | Public JSON fetched from GitHub raw; no session data sent. |

## Data at rest

- The local archive (SQLite + FTS5 index) stores indexed session data in
plaintext. This includes assistant responses, user prompts, tool arguments,
command output, file contents fetched by agents, and any secrets that may have
been pasted into an agent session.
command output, file contents fetched by agents, and any secrets that may
have been pasted into an agent session.
- File permissions follow the user's umask. agentsview does not chmod the data
directory and does not encrypt at rest.
- Treat the agentsview data directory with the same care you would treat your
Expand All @@ -89,10 +90,10 @@ explicitly because "data stays on your machine" is the default but is not a
complete description of the system once optional features are in use.

- **Local UI / API.** The HTTP server binds to `127.0.0.1` by default. When
exposed beyond loopback, `--require-auth` should be enabled. Authentication is
a bearer token applied to `/api/` routes only; static assets remain ungated.
Browser-facing defenses (Host-header allowlist, CORS restrictions, CSP,
`X-Frame-Options: DENY`) are always on. See the CLI reference for token
exposed beyond loopback, `--require-auth` should be enabled. Authentication
is a bearer token applied to `/api/` routes only; static assets remain
ungated. Browser-facing defenses (Host-header allowlist, CORS restrictions,
CSP, `X-Frame-Options: DENY`) are always on. See the CLI reference for token
configuration.
- **PostgreSQL sync.** `agentsview pg push` exports the local archive to a
user-supplied PostgreSQL instance. Non-loopback DSNs are rejected unless TLS
Expand All @@ -102,8 +103,14 @@ complete description of the system once optional features are in use.
its access controls.
- **SSH remote sync.** agentsview can pull session archives from another machine
over SSH. Authentication is whatever the user's SSH configuration provides.
Pulled files are parsed locally as untrusted data and merged into the unified
archive.
Pulled files are parsed locally as untrusted data and merged into the
unified archive.
- **Artifact HTTP peer sync.** `agentsview sync http(s)://...` exchanges bearer
credentials and archive artifacts with a user-supplied peer. Non-loopback
peers require HTTPS by default; loopback HTTP is allowed, while remote
plaintext requires the deliberate `--allow-insecure` opt-in and emits a
warning. Redirects are rejected. Received artifacts remain untrusted
structured input even when the peer is part of a trusted personal fleet.
- **Imports.** Imported archives from other agentsview instances or third-party
exports are treated as untrusted structured data, no different from session
files written by local agents.
Expand All @@ -125,9 +132,9 @@ The HTTP server applies the following defenses unconditionally:
- **CORS restrictions.** Cross-origin API requests must come from an allowed
origin or carry the bearer token; preflight handling is explicit.
- **Content-Security-Policy.** A policy pinning the server's exact origin for
script/style/image/font/default-src is set on non-API responses. `connect-src`
is intentionally widened to allow the remote-server feature in the SPA; this
is a documented tradeoff.
script/style/image/font/default-src is set on non-API responses.
`connect-src` is intentionally widened to allow the remote-server feature in
the SPA; this is a documented tradeoff.
- **X-Frame-Options: DENY.** Framing is disallowed on non-API responses.

## Secrets subsystem
Expand Down Expand Up @@ -181,11 +188,11 @@ its own proposal.
1. **Multi-user machine support.** Is agentsview ever meant to run on a shared
host, and if so what are the minimum hardening steps?
1. **`allow_insecure` UX.** Should setting `[pg] allow_insecure = true` require
an additional confirmation (e.g., a `--yes-really` flag) on first use, given
that it disables the only protection against plaintext PG egress?
an additional confirmation (e.g., a `--yes-really` flag) on first use,
given that it disables the only protection against plaintext PG egress?
1. **Deletion guarantees.** Should "permanent delete" grow into a stronger
erasure path (VACUUM, WAL checkpoint + truncate, mirror propagation to PG/SSH
targets), or should the docs simply make the current limits clearer?
erasure path (VACUUM, WAL checkpoint + truncate, mirror propagation to
PG/SSH targets), or should the docs simply make the current limits clearer?
1. **Secret detection scope.** Should the detector expand (more patterns,
structured-secret types), should redacted-by-default extend to exports, and
should there be a "scrub-on-import" pass?
73 changes: 69 additions & 4 deletions cmd/agentsview/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ func newOpenAPICommand() *cobra.Command {
func newSyncCommand() *cobra.Command {
var cfg SyncConfig
cmd := &cobra.Command{
Use: "sync",
Use: "sync [artifact-folder]",
Short: "Sync session data without serving",
Long: "Sync session data into the local database without starting the\n" +
"HTTP server.\n\n" +
Expand All @@ -309,12 +309,33 @@ func newSyncCommand() *cobra.Command {
"exits non-zero if any configured host failed.\n\n" +
"With --host, syncs only that host. A running local daemon may use a\n" +
"matching configured remote_hosts entry and transport; otherwise,\n" +
"ad hoc --host sync uses your existing SSH configuration and requires\n" +
"ad hoc --host sync falls back to SSH.\n\n" +
"With an artifact-folder argument or --artifact-folder, sync also\n" +
"exchanges local-first immutable artifacts with that folder target.\n" +
"Artifact sync v1 is for a fully trusted personal fleet. Use a\n" +
"dedicated artifact share folder; do not point this at the\n" +
"agentsview data directory, raw agent directories, or the live\n" +
"SQLite database file and its WAL/SHM files.\n\n" +
"Use --init with an artifact folder on first setup to generate and\n" +
"persist this machine's artifact origin, backfill existing local\n" +
"sessions into the artifact store, exchange with the folder target,\n" +
"and import any peer artifacts already present. Two intermittent\n" +
"machines only sync while both can reach the same transport; use a\n" +
"NAS, cloud folder, object store, or always-on peer as a rendezvous\n" +
"when asynchronous convergence matters.\n\n" +
"Use --watch with an artifact folder to keep syncing. Watch mode\n" +
"runs an initial local sync and artifact exchange, coalesces file\n" +
"changes with --debounce, retries failed exchanges on later\n" +
"changes or --interval ticks, and performs a final best-effort\n" +
"exchange on shutdown. Combining --init with --watch publishes\n" +
"the first-run baseline on the first successful exchange, then\n" +
"keeps watching.\n\n" +
"Remote sync uses your existing SSH configuration and requires\n" +
"key-based (passwordless) auth; it never prompts for a password.",
GroupID: groupCore,
SilenceUsage: true,
Args: cobra.NoArgs,
PreRunE: func(cmd *cobra.Command, _ []string) error {
Args: cobra.MaximumNArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
if cfg.Host == "" {
if cmd.Flags().Changed("user") ||
cmd.Flags().Changed("port") {
Expand All @@ -323,16 +344,34 @@ func newSyncCommand() *cobra.Command {
)
}
}
if err := applySyncArtifactTarget(&cfg, args, cmd.Flags().Changed("artifact-folder")); err != nil {
return err
}
if err := validateSyncConfig(cfg); err != nil {
return err
}
return nil
},
Run: func(cmd *cobra.Command, args []string) {
if cfg.Watch {
runSyncWatch(cfg)
return
}
if cmd.Flags().Changed("debounce") || cmd.Flags().Changed("interval") {
fmt.Fprintln(os.Stderr,
"warning: --debounce and --interval have no effect without --watch")
}
runSync(cfg)
},
}
cmd.Flags().BoolVar(
&cfg.Full, "full", false,
"Force a full resync regardless of data version",
)
cmd.Flags().BoolVar(
&cfg.Init, "init", false,
"Initialize artifact sync with the folder target",
)
cmd.Flags().StringVar(
&cfg.Host, "host", "",
"SSH hostname for deprecated remote sync",
Expand All @@ -345,6 +384,30 @@ func newSyncCommand() *cobra.Command {
&cfg.Port, "port", 0,
"SSH port for deprecated remote sync (default: 22)",
)
cmd.Flags().StringVar(
&cfg.ArtifactFolder, "artifact-folder", "",
"Exchange local-first sync artifacts with a folder, http(s) peer, or s3:// target",
)
cmd.Flags().StringVar(
&cfg.Token, "token", "",
"Bearer token for an http(s) artifact peer target",
)
cmd.Flags().BoolVar(
&cfg.AllowInsecure, "allow-insecure", false,
"Allow plaintext HTTP to a non-loopback artifact peer",
)
cmd.Flags().BoolVar(
&cfg.Watch, "watch", false,
"Run artifact folder sync continuously, syncing on change plus a periodic floor",
)
cmd.Flags().DurationVar(
&cfg.Debounce, "debounce", defaultWatchDebounce,
"Coalesce window after a change before artifact sync (--watch only)",
)
cmd.Flags().DurationVar(
&cfg.Interval, "interval", defaultWatchInterval,
"Periodic floor artifact sync interval (--watch only)",
)
cmd.Flags().StringVar(
&cfg.CPUProfile, "cpuprofile", "",
"Write CPU profile to file (developer use)",
Expand All @@ -362,6 +425,8 @@ func newSyncCommand() *cobra.Command {
panic(err)
}
}
cmd.AddCommand(newSyncGCCommand())
cmd.AddCommand(newSyncArtifactResetCommand())
return cmd
}

Expand Down
11 changes: 10 additions & 1 deletion cmd/agentsview/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ func TestDuckDBPushHelpShowsProjectFlags(t *testing.T) {
}
}

func TestSyncHelpShowsArtifactTransportSafetyFlag(t *testing.T) {
help, err := executeCommand(newRootCommand(), "sync", "--help")
require.NoError(t, err, "Execute")
assert.Contains(t, help, "--allow-insecure")
assert.Contains(t, help, "non-loopback artifact peer")
}

func TestPGStatusHelpShowsProjectFlags(t *testing.T) {
help, err := executeCommand(newRootCommand(), "pg", "status", "--help")
require.NoError(t, err, "Execute")
Expand Down Expand Up @@ -144,6 +151,8 @@ func TestOpenAPICommandEmitsSpec(t *testing.T) {
assert.Contains(t, spec.Paths["/api/v1/sessions"], "get")
require.Contains(t, spec.Paths, "/api/v1/sessions/{id}/rename")
assert.Contains(t, spec.Paths["/api/v1/sessions/{id}/rename"], "patch")
require.Contains(t, spec.Paths, "/api/v1/artifacts/peers")
assert.Contains(t, spec.Paths["/api/v1/artifacts/peers"], "get")
}

func TestServeCheckDataVersionRejectsNewerDatabase(t *testing.T) {
Expand Down Expand Up @@ -362,7 +371,7 @@ func TestRootHelpDocumentsRemoteHosts(t *testing.T) {
func TestSyncHelpMentionsConfiguredHosts(t *testing.T) {
help, err := executeCommand(newRootCommand(), "sync", "--help")
require.NoError(t, err, "Execute")
for _, want := range []string{"remote_hosts", "--host", "passwordless"} {
for _, want := range []string{"remote_hosts", "--host", "passwordless", "trusted personal fleet", "rendezvous"} {
assert.Contains(t, help, want, "sync help missing %q", want)
}
}
56 changes: 56 additions & 0 deletions cmd/agentsview/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
_ "time/tzdata"

"github.com/spf13/cobra"
"go.kenn.io/agentsview/internal/artifact"
"go.kenn.io/agentsview/internal/config"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/parser"
Expand Down Expand Up @@ -460,6 +461,25 @@ func runServe(cfg config.Config, opts serveOptions) {
}
cfg = preparedCfg

// Reconcile an already-adopted artifact origin so every origin lookup
// (recorder, peer import, folder sync) agrees: the config.toml origin is
// authoritative and overwrites a divergent DB sync-state value. Serve
// never creates an origin — a machine opts into artifact sync only via
// `sync --init`, a sync run, or an incoming peer exchange, and until then
// curation stays local with no metadata ledger writes.
if cfg.DataDir != "" && !database.ReadOnly() && cfg.ArtifactOriginID != "" {
if err := artifact.AdoptOrigin(database, cfg.ArtifactOriginID); err != nil {
fatal("reconcile artifact origin id: %v", err)
}
}
artifactRepository, err := openServeArtifactStore(ctx, cfg.DataDir)
if err != nil {
fatal("open artifact store: %v", err)
}
if err := recoverServeArtifactRepository(ctx, database, artifactRepository); err != nil {
fatal("recover artifact repository reset: %v", errors.Join(err, artifactRepository.Close()))
}

srvOpts := []server.Option{
server.WithVersion(server.VersionInfo{
Version: version,
Expand All @@ -472,6 +492,7 @@ func runServe(cfg config.Config, opts serveOptions) {
server.WithIdleTracker(idleTracker),
server.WithHTTPRemoteCleanupRegistry(httpRemoteCleanupRegistry),
server.WithPprof(opts.Pprof),
server.WithArtifactRepository(artifactRepository),
}
srvOpts = append(srvOpts, vectorServe.ServerOpts...)
if src := newVectorPushSource(cfg); src != nil {
Expand Down Expand Up @@ -588,6 +609,41 @@ func runServe(cfg config.Config, opts serveOptions) {
}
}

func openServeArtifactStore(ctx context.Context, dataDir string) (*artifact.Repository, error) {
repository, err := artifact.OpenRepository(ctx, dataDir)
if err != nil {
return nil, err
}
if err := repository.RecoverPacking(ctx); err != nil {
return nil, errors.Join(err, repository.Close())
}
return repository, nil
}

func recoverServeArtifactRepository(
ctx context.Context, database *db.DB, repository *artifact.Repository,
) error {
if database == nil || database.ReadOnly() {
return nil
}
origin, err := artifact.StoredOrigin(database)
if err != nil || origin == "" {
return err
}
_, recovered, err := artifact.RecoverRepositoryResetRepublish(ctx, database, repository, origin)
if err == nil && recovered {
repository.NotifyBatch(ctx)
}
if err != nil {
return err
}
coordinator := artifact.NewStoreImportCoordinator(
database, repository.Content(), origin,
)
_, err = coordinator.Finalize(ctx)
return err
}

func runDeferredStartupSyncFallback(
ctx context.Context,
cfg config.Config,
Expand Down
Loading
Loading