Compare commits
5 Commits
0d45ac6e3c
...
61e5b0fe58
| Author | SHA1 | Date | |
|---|---|---|---|
| 61e5b0fe58 | |||
| f3c21c7d9f | |||
| bc5f5d3731 | |||
| 93a76f1d36 | |||
| 5c882f26a9 |
@@ -83,9 +83,12 @@ Notes:
|
|||||||
- `--prompt-dir <dir>`: prompt definition directory.
|
- `--prompt-dir <dir>`: prompt definition directory.
|
||||||
- `--profile-dir <dir>`: custom profile definition directory.
|
- `--profile-dir <dir>`: custom profile definition directory.
|
||||||
- `--schema-dir <dir>`: schema base directory for `json_schema` validation.
|
- `--schema-dir <dir>`: schema base directory for `json_schema` validation.
|
||||||
|
- `--artifact-root <dir>`: base directory for HTTP `file` input references.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- `serve` does not accept runtime model override flags such as `--model` or `--llm-base-url`.
|
- `serve` does not accept runtime model override flags such as `--model` or `--llm-base-url`.
|
||||||
|
- HTTP `file` input references are rejected unless an artifact root is configured through `server.artifact_root` or `--artifact-root`.
|
||||||
|
- `--artifact-root` affects only `serve`; `run` and `render` file input paths are unchanged.
|
||||||
|
|
||||||
## Input And Variable Syntax
|
## Input And Variable Syntax
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ schema_dir: /opt/scriptorium/schemas
|
|||||||
|
|
||||||
server:
|
server:
|
||||||
addr: 127.0.0.1:8080
|
addr: 127.0.0.1:8080
|
||||||
|
artifact_root: /var/lib/scriptorium/artifacts
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
render_format: text
|
render_format: text
|
||||||
@@ -47,12 +48,14 @@ Top-level fields:
|
|||||||
- `profile_dir` (optional): default custom profile definition directory.
|
- `profile_dir` (optional): default custom profile definition directory.
|
||||||
- `schema_dir` (optional): base directory for schema files used by `json_schema` validation.
|
- `schema_dir` (optional): base directory for schema files used by `json_schema` validation.
|
||||||
- `server.addr` (optional): default listen address for `serve`.
|
- `server.addr` (optional): default listen address for `serve`.
|
||||||
|
- `server.artifact_root` (optional): base directory for HTTP `file` input references.
|
||||||
- `defaults.render_format` (optional): default `render` output format (`text` or `json`).
|
- `defaults.render_format` (optional): default `render` output format (`text` or `json`).
|
||||||
|
|
||||||
Built-in defaults:
|
Built-in defaults:
|
||||||
|
|
||||||
- `schema_dir`: `.`
|
- `schema_dir`: `.`
|
||||||
- `server.addr`: `:8080`
|
- `server.addr`: `:8080`
|
||||||
|
- `server.artifact_root`: unset; HTTP `file` input references are rejected until configured.
|
||||||
- `defaults.render_format`: `text`
|
- `defaults.render_format`: `text`
|
||||||
|
|
||||||
Validation behavior:
|
Validation behavior:
|
||||||
@@ -60,6 +63,15 @@ Validation behavior:
|
|||||||
- Config decoding is strict; unknown YAML fields are rejected.
|
- Config decoding is strict; unknown YAML fields are rejected.
|
||||||
- Raw API key fields are not supported in `config.yml`.
|
- Raw API key fields are not supported in `config.yml`.
|
||||||
|
|
||||||
|
HTTP artifact root behavior:
|
||||||
|
|
||||||
|
- `server.artifact_root` applies only to `serve`.
|
||||||
|
- HTTP `inline` input references work without an artifact root.
|
||||||
|
- HTTP `file` input references are resolved against `server.artifact_root` and must stay inside it.
|
||||||
|
- Relative traversal and absolute paths outside the root are rejected.
|
||||||
|
- Symlinks inside the root are followed by the operating system; do not make the artifact root writable by untrusted users.
|
||||||
|
- CLI `run` and `render` file inputs keep their normal direct filesystem path behavior.
|
||||||
|
|
||||||
## Prompt Definition Files
|
## Prompt Definition Files
|
||||||
|
|
||||||
Prompt definitions are YAML files anywhere under `prompt_dir`, including nested subdirectories.
|
Prompt definitions are YAML files anywhere under `prompt_dir`, including nested subdirectories.
|
||||||
@@ -269,6 +281,9 @@ Rules:
|
|||||||
- Invalid generated JSON causes validation status `failed` (not a runtime error).
|
- Invalid generated JSON causes validation status `failed` (not a runtime error).
|
||||||
|
|
||||||
Supported artifact reference types for request inputs are `file` and `inline`.
|
Supported artifact reference types for request inputs are `file` and `inline`.
|
||||||
|
For HTTP `serve`, `file` references require `server.artifact_root` and must stay
|
||||||
|
inside that root. CLI `run` and `render` file inputs are not restricted by
|
||||||
|
`server.artifact_root`.
|
||||||
|
|
||||||
## Secrets Handling
|
## Secrets Handling
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ Input reference types currently supported by runtime artifact loading:
|
|||||||
- `file`
|
- `file`
|
||||||
- `inline`
|
- `inline`
|
||||||
|
|
||||||
|
HTTP `file` references require `server.artifact_root` or `serve --artifact-root`.
|
||||||
|
Relative file URIs resolve inside that root. Absolute file URIs are accepted
|
||||||
|
only when they remain inside the root. Requests that escape the root, including
|
||||||
|
`..` traversal and absolute paths outside the root, return
|
||||||
|
`400 artifact_not_allowed`. `inline` references do not require an artifact root.
|
||||||
|
|
||||||
Model override notes:
|
Model override notes:
|
||||||
|
|
||||||
- Numeric model override fields distinguish omitted values from explicit zero values. For example, omitting `temperature` preserves the selected profile/default value, while `"temperature": 0` explicitly sets the effective temperature to zero.
|
- Numeric model override fields distinguish omitted values from explicit zero values. For example, omitting `temperature` preserves the selected profile/default value, while `"temperature": 0` explicitly sets the effective temperature to zero.
|
||||||
@@ -194,6 +200,7 @@ Current error mapping (non-exhaustive):
|
|||||||
- `400 profile_required`: no explicit `profile_id` and prompt has no `default_profile`
|
- `400 profile_required`: no explicit `profile_id` and prompt has no `default_profile`
|
||||||
- `400 prompt_load_failed`: prompt definition invalid/unloadable
|
- `400 prompt_load_failed`: prompt definition invalid/unloadable
|
||||||
- `400 profile_load_failed`: profile invalid/unloadable
|
- `400 profile_load_failed`: profile invalid/unloadable
|
||||||
|
- `400 artifact_not_allowed`: file input artifact is outside the configured artifact root or file refs are not enabled
|
||||||
- `400 artifact_read_failed`: input artifact loading failed
|
- `400 artifact_read_failed`: input artifact loading failed
|
||||||
- `400 prompt_render_failed`: template render failed
|
- `400 prompt_render_failed`: template render failed
|
||||||
- `400 api_key_env_missing`: named API-key environment variable is missing
|
- `400 api_key_env_missing`: named API-key environment variable is missing
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ This document describes implemented adapter/repository boundaries and their curr
|
|||||||
- root package `scriptorium`: public Go library facade for preparing and running prompt requests.
|
- root package `scriptorium`: public Go library facade for preparing and running prompt requests.
|
||||||
- `internal/promptdef`: filesystem and `fs.FS` prompt-definition repositories.
|
- `internal/promptdef`: filesystem and `fs.FS` prompt-definition repositories.
|
||||||
- `internal/profile`: filesystem, `fs.FS`, and overlay execution-profile repositories.
|
- `internal/profile`: filesystem, `fs.FS`, and overlay execution-profile repositories.
|
||||||
|
- `internal/filecatalog`: shared YAML discovery and display-path helpers for prompt/profile repositories.
|
||||||
- `internal/profile/builtin`: embedded built-in execution-profile repository.
|
- `internal/profile/builtin`: embedded built-in execution-profile repository.
|
||||||
- `internal/artifact`: input artifact reader.
|
- `internal/artifact`: input artifact reader.
|
||||||
- `internal/prompt`: Go-template renderer.
|
- `internal/prompt`: Go-template renderer.
|
||||||
@@ -45,6 +46,7 @@ Prompt/profile repositories:
|
|||||||
|
|
||||||
- Input: prompt/profile YAML files under configured directories or `fs.FS` roots.
|
- Input: prompt/profile YAML files under configured directories or `fs.FS` roots.
|
||||||
- Output: normalized domain definitions/profiles or typed errors.
|
- Output: normalized domain definitions/profiles or typed errors.
|
||||||
|
- Shared YAML catalog helpers provide recursive discovery, extension filtering, deterministic ordering, file stems, and `fs.FS` display paths.
|
||||||
- Single-file public sources are represented as `fs.FS` roots containing one YAML file; lookup still uses YAML `id` values.
|
- Single-file public sources are represented as `fs.FS` roots containing one YAML file; lookup still uses YAML `id` values.
|
||||||
|
|
||||||
Profile repository composition:
|
Profile repository composition:
|
||||||
@@ -85,6 +87,7 @@ Primary app settings consumed by adapters:
|
|||||||
- `profile_dir` (optional custom profile source)
|
- `profile_dir` (optional custom profile source)
|
||||||
- `schema_dir`
|
- `schema_dir`
|
||||||
- `server.addr`
|
- `server.addr`
|
||||||
|
- `server.artifact_root` (HTTP `serve` file input root)
|
||||||
- `defaults.render_format`
|
- `defaults.render_format`
|
||||||
|
|
||||||
Execution profile/request settings used through runner:
|
Execution profile/request settings used through runner:
|
||||||
@@ -116,6 +119,10 @@ Artifact refs:
|
|||||||
|
|
||||||
- Supported reference types: `inline`, `file`.
|
- Supported reference types: `inline`, `file`.
|
||||||
- Unsupported types return `ErrUnsupportedRefType`.
|
- Unsupported types return `ErrUnsupportedRefType`.
|
||||||
|
- CLI `run` and `render` use direct filesystem file reads for `file` references.
|
||||||
|
- HTTP `serve` uses a restricted artifact reader: `inline` references work without a root, while `file` references require `server.artifact_root` or `--artifact-root` and must stay inside that root.
|
||||||
|
- HTTP file paths are resolved with clean absolute paths and containment checks, not string-prefix checks.
|
||||||
|
- Symlinks inside the root are followed by the operating system; the configured root must not be writable by untrusted users.
|
||||||
|
|
||||||
LLM adapter:
|
LLM adapter:
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ Built-in defaults relevant to operations:
|
|||||||
|
|
||||||
- `schema_dir: .`
|
- `schema_dir: .`
|
||||||
- `server.addr: :8080`
|
- `server.addr: :8080`
|
||||||
|
- `server.artifact_root`: unset; HTTP `file` input references are rejected until configured
|
||||||
- `defaults.render_format: text`
|
- `defaults.render_format: text`
|
||||||
|
|
||||||
## Normal CLI Workflow
|
## Normal CLI Workflow
|
||||||
@@ -75,11 +76,14 @@ Current inbound API behavior:
|
|||||||
- Route: `POST /v1/runs`
|
- Route: `POST /v1/runs`
|
||||||
- JSON request parsing rejects unknown fields.
|
- JSON request parsing rejects unknown fields.
|
||||||
- Validation content failures still return `200 OK` with `validation.status: "failed"`.
|
- Validation content failures still return `200 OK` with `validation.status: "failed"`.
|
||||||
|
- `inline` input references work without filesystem configuration.
|
||||||
|
- `file` input references require `server.artifact_root` or `serve --artifact-root`; relative paths resolve inside that root and paths outside it are rejected.
|
||||||
|
|
||||||
Security caveat:
|
Security caveat:
|
||||||
|
|
||||||
- `serve` has no built-in authentication or authorization.
|
- `serve` has no built-in authentication or authorization.
|
||||||
- Deploy only behind trusted controls (private network boundary, authenticated reverse proxy, API gateway, or equivalent).
|
- Deploy only behind trusted controls (private network boundary, authenticated reverse proxy, API gateway, or equivalent).
|
||||||
|
- Keep the HTTP artifact root as narrow as practical and do not make it writable by untrusted users.
|
||||||
|
|
||||||
## Output, Logs, And Exit Codes
|
## Output, Logs, And Exit Codes
|
||||||
|
|
||||||
|
|||||||
@@ -146,26 +146,33 @@ Symptom:
|
|||||||
|
|
||||||
- CLI run/render error reading input artifacts.
|
- CLI run/render error reading input artifacts.
|
||||||
- HTTP `400 artifact_read_failed`.
|
- HTTP `400 artifact_read_failed`.
|
||||||
|
- HTTP `400 artifact_not_allowed`.
|
||||||
|
|
||||||
Likely cause:
|
Likely cause:
|
||||||
|
|
||||||
- File path in input mapping does not exist or is unreadable.
|
- File path in input mapping does not exist or is unreadable.
|
||||||
- Unsupported artifact reference type in HTTP request.
|
- Unsupported artifact reference type in HTTP request.
|
||||||
|
- HTTP `file` input references are disabled because no artifact root is configured.
|
||||||
|
- HTTP `file` input path escapes the configured artifact root.
|
||||||
|
|
||||||
Diagnostic step:
|
Diagnostic step:
|
||||||
|
|
||||||
- Verify every mapped file path exists and is readable by the process.
|
- Verify every mapped file path exists and is readable by the process.
|
||||||
- For HTTP, verify each input uses supported `type` values.
|
- For HTTP, verify each input uses supported `type` values.
|
||||||
|
- For HTTP `file` inputs, verify `server.artifact_root` or `serve --artifact-root` is configured and the requested path stays inside that root.
|
||||||
|
|
||||||
Safe fix:
|
Safe fix:
|
||||||
|
|
||||||
- Correct file paths and permissions.
|
- Correct file paths and permissions.
|
||||||
- Use supported input types (`file`, `inline`).
|
- Use supported input types (`file`, `inline`).
|
||||||
|
- Configure a narrow HTTP artifact root when HTTP file inputs are required.
|
||||||
|
- Use relative paths under the artifact root, or switch to `inline` inputs.
|
||||||
|
|
||||||
Relevant links:
|
Relevant links:
|
||||||
|
|
||||||
- [CLI reference](cli.md)
|
- [CLI reference](cli.md)
|
||||||
- [Configuration reference](config.md)
|
- [Configuration reference](config.md)
|
||||||
|
- [HTTP API integration](integrations/http-api.md)
|
||||||
|
|
||||||
## Prompt Template Render Failures
|
## Prompt Template Render Failures
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ schema_dir: ./examples/schemas
|
|||||||
|
|
||||||
server:
|
server:
|
||||||
addr: :8080
|
addr: :8080
|
||||||
|
artifact_root: .
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
render_format: text
|
render_format: text
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ type serveConfig struct {
|
|||||||
promptDir string
|
promptDir string
|
||||||
profileDir string
|
profileDir string
|
||||||
schemaDir string
|
schemaDir string
|
||||||
|
artifactRoot string
|
||||||
}
|
}
|
||||||
|
|
||||||
type commonCommandSettings struct {
|
type commonCommandSettings struct {
|
||||||
@@ -85,6 +86,7 @@ type commonCommandSettings struct {
|
|||||||
profileDir string
|
profileDir string
|
||||||
schemaDir string
|
schemaDir string
|
||||||
serverAddr string
|
serverAddr string
|
||||||
|
artifactRoot string
|
||||||
defaultRenderFormat renderformat.PreparedRunOutputFormat
|
defaultRenderFormat renderformat.PreparedRunOutputFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +204,13 @@ func serveCommand(args []string, stderr io.Writer) int {
|
|||||||
return ExitRuntimeError
|
return ExitRuntimeError
|
||||||
}
|
}
|
||||||
|
|
||||||
runner := newRunner(cfg.promptDir, cfg.profileDir, cfg.schemaDir, llmClient)
|
artifactReader, err := artifactadapter.NewRestrictedCompositeReader(cfg.artifactRoot)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "artifact root error: %v\n", err)
|
||||||
|
return ExitRuntimeError
|
||||||
|
}
|
||||||
|
|
||||||
|
runner := newRunnerWithArtifactReader(cfg.promptDir, cfg.profileDir, cfg.schemaDir, llmClient, artifactReader)
|
||||||
|
|
||||||
h := httpadapter.NewHandler(runner)
|
h := httpadapter.NewHandler(runner)
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
@@ -281,6 +289,7 @@ func parseServeArgs(args []string) (*serveConfig, error) {
|
|||||||
fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definition YAML files")
|
fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definition YAML files")
|
||||||
fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profile YAML files")
|
fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profile YAML files")
|
||||||
fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas")
|
fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas")
|
||||||
|
fs.StringVar(&cfg.artifactRoot, "artifact-root", "", "base directory for HTTP file input artifacts")
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -294,6 +303,7 @@ func parseServeArgs(args []string) (*serveConfig, error) {
|
|||||||
ProfileDir: cfg.profileDirIfSet(fs),
|
ProfileDir: cfg.profileDirIfSet(fs),
|
||||||
SchemaDir: cfg.schemaDirIfSet(fs),
|
SchemaDir: cfg.schemaDirIfSet(fs),
|
||||||
ServerAddr: cfg.addrIfSet(fs),
|
ServerAddr: cfg.addrIfSet(fs),
|
||||||
|
ArtifactRoot: cfg.artifactRootIfSet(fs),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -303,6 +313,7 @@ func parseServeArgs(args []string) (*serveConfig, error) {
|
|||||||
cfg.profileDir = settings.profileDir
|
cfg.profileDir = settings.profileDir
|
||||||
cfg.schemaDir = settings.schemaDir
|
cfg.schemaDir = settings.schemaDir
|
||||||
cfg.addr = settings.serverAddr
|
cfg.addr = settings.serverAddr
|
||||||
|
cfg.artifactRoot = settings.artifactRoot
|
||||||
|
|
||||||
if err := validateRequiredLibraryDirs(cfg.promptDir); err != nil {
|
if err := validateRequiredLibraryDirs(cfg.promptDir); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -313,6 +324,9 @@ func parseServeArgs(args []string) (*serveConfig, error) {
|
|||||||
cfg.profileDir = filepath.Clean(cfg.profileDir)
|
cfg.profileDir = filepath.Clean(cfg.profileDir)
|
||||||
}
|
}
|
||||||
cfg.schemaDir = filepath.Clean(cfg.schemaDir)
|
cfg.schemaDir = filepath.Clean(cfg.schemaDir)
|
||||||
|
if strings.TrimSpace(cfg.artifactRoot) != "" {
|
||||||
|
cfg.artifactRoot = filepath.Clean(cfg.artifactRoot)
|
||||||
|
}
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +443,13 @@ func (c *serveConfig) addrIfSet(fs *flag.FlagSet) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *serveConfig) artifactRootIfSet(fs *flag.FlagSet) string {
|
||||||
|
if flagWasSet(fs, "artifact-root") {
|
||||||
|
return c.artifactRoot
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func registerConfigPathFlag(fs *flag.FlagSet, target *string) {
|
func registerConfigPathFlag(fs *flag.FlagSet, target *string) {
|
||||||
fs.StringVar(
|
fs.StringVar(
|
||||||
target,
|
target,
|
||||||
@@ -466,6 +487,7 @@ func resolveCommonSettings(fs *flag.FlagSet, configPath string, overrides appcon
|
|||||||
profileDir: settings.ProfileDir,
|
profileDir: settings.ProfileDir,
|
||||||
schemaDir: settings.SchemaDir,
|
schemaDir: settings.SchemaDir,
|
||||||
serverAddr: settings.ServerAddr,
|
serverAddr: settings.ServerAddr,
|
||||||
|
artifactRoot: settings.ArtifactRoot,
|
||||||
defaultRenderFormat: settings.DefaultRenderFormat,
|
defaultRenderFormat: settings.DefaultRenderFormat,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -478,10 +500,17 @@ func validateRequiredLibraryDirs(promptDir string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newRunner(promptDir, profileDir, schemaDir string, llmClient llm.Client) *usecase.Runner {
|
func newRunner(promptDir, profileDir, schemaDir string, llmClient llm.Client) *usecase.Runner {
|
||||||
|
return newRunnerWithArtifactReader(promptDir, profileDir, schemaDir, llmClient, artifactadapter.NewCompositeReader())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRunnerWithArtifactReader(promptDir, profileDir, schemaDir string, llmClient llm.Client, artifactReader artifactadapter.Reader) *usecase.Runner {
|
||||||
|
if artifactReader == nil {
|
||||||
|
artifactReader = artifactadapter.NewCompositeReader()
|
||||||
|
}
|
||||||
return usecase.NewRunner(
|
return usecase.NewRunner(
|
||||||
promptdef.NewFilesystemRepository(promptDir),
|
promptdef.NewFilesystemRepository(promptDir),
|
||||||
builtin.NewRepositoryWithDirectory(profileDir),
|
builtin.NewRepositoryWithDirectory(profileDir),
|
||||||
artifactadapter.NewCompositeReader(),
|
artifactReader,
|
||||||
prompt.NewGoRenderer(),
|
prompt.NewGoRenderer(),
|
||||||
llmClient,
|
llmClient,
|
||||||
validate.NewStandardValidator(schemaDir),
|
validate.NewStandardValidator(schemaDir),
|
||||||
|
|||||||
@@ -435,11 +435,13 @@ profile_dir: ./from-config/profiles
|
|||||||
schema_dir: ./from-config/schemas
|
schema_dir: ./from-config/schemas
|
||||||
server:
|
server:
|
||||||
addr: 127.0.0.1:9000
|
addr: 127.0.0.1:9000
|
||||||
|
artifact_root: ./from-config/artifacts
|
||||||
`)
|
`)
|
||||||
|
|
||||||
cfg, err := parseServeArgs([]string{
|
cfg, err := parseServeArgs([]string{
|
||||||
"--config", configPath,
|
"--config", configPath,
|
||||||
"--addr", ":7777",
|
"--addr", ":7777",
|
||||||
|
"--artifact-root", "./from-cli/artifacts",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected valid args, got %v", err)
|
t.Fatalf("expected valid args, got %v", err)
|
||||||
@@ -457,6 +459,9 @@ server:
|
|||||||
if cfg.addr != ":7777" {
|
if cfg.addr != ":7777" {
|
||||||
t.Fatalf("expected CLI addr override, got %q", cfg.addr)
|
t.Fatalf("expected CLI addr override, got %q", cfg.addr)
|
||||||
}
|
}
|
||||||
|
if cfg.artifactRoot != filepath.Clean("./from-cli/artifacts") {
|
||||||
|
t.Fatalf("expected CLI artifact root override, got %q", cfg.artifactRoot)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseServeArgsWithConfigProvidesRequiredDirectoriesAndAddr(t *testing.T) {
|
func TestParseServeArgsWithConfigProvidesRequiredDirectoriesAndAddr(t *testing.T) {
|
||||||
@@ -466,6 +471,7 @@ profile_dir: ./from-config/profiles
|
|||||||
schema_dir: ./from-config/schemas
|
schema_dir: ./from-config/schemas
|
||||||
server:
|
server:
|
||||||
addr: 127.0.0.1:9000
|
addr: 127.0.0.1:9000
|
||||||
|
artifact_root: ./from-config/artifacts
|
||||||
`)
|
`)
|
||||||
|
|
||||||
cfg, err := parseServeArgs([]string{
|
cfg, err := parseServeArgs([]string{
|
||||||
@@ -487,6 +493,9 @@ server:
|
|||||||
if cfg.addr != "127.0.0.1:9000" {
|
if cfg.addr != "127.0.0.1:9000" {
|
||||||
t.Fatalf("expected addr from config, got %q", cfg.addr)
|
t.Fatalf("expected addr from config, got %q", cfg.addr)
|
||||||
}
|
}
|
||||||
|
if cfg.artifactRoot != filepath.Clean("./from-config/artifacts") {
|
||||||
|
t.Fatalf("expected artifact root from config, got %q", cfg.artifactRoot)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunAndRenderBuildEquivalentRuntimeOverrideRequestsForSharedFlags(t *testing.T) {
|
func TestRunAndRenderBuildEquivalentRuntimeOverrideRequestsForSharedFlags(t *testing.T) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
|
||||||
@@ -187,6 +188,8 @@ func mapRunError(err error) (int, string, string) {
|
|||||||
return http.StatusBadRequest, "prompt_load_failed", "failed to load prompt definition"
|
return http.StatusBadRequest, "prompt_load_failed", "failed to load prompt definition"
|
||||||
case errors.Is(err, usecase.ErrProfileLoad):
|
case errors.Is(err, usecase.ErrProfileLoad):
|
||||||
return http.StatusBadRequest, "profile_load_failed", "failed to load execution profile"
|
return http.StatusBadRequest, "profile_load_failed", "failed to load execution profile"
|
||||||
|
case errors.Is(err, artifact.ErrFileNotAllowed), errors.Is(err, artifact.ErrFileOutsideRoot):
|
||||||
|
return http.StatusBadRequest, "artifact_not_allowed", "file input artifact is not allowed"
|
||||||
case errors.Is(err, usecase.ErrArtifactLoad):
|
case errors.Is(err, usecase.ErrArtifactLoad):
|
||||||
return http.StatusBadRequest, "artifact_read_failed", "failed to read input artifact"
|
return http.StatusBadRequest, "artifact_read_failed", "failed to read input artifact"
|
||||||
case errors.Is(err, usecase.ErrPromptRender):
|
case errors.Is(err, usecase.ErrPromptRender):
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/llm"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/llm"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
|
||||||
@@ -61,6 +64,12 @@ func (handlerRenderer) Render(ctx context.Context, definition *domain.PromptDefi
|
|||||||
return &domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}}, nil
|
return &domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type handlerLLMClient struct{}
|
||||||
|
|
||||||
|
func (handlerLLMClient) Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
||||||
|
return &domain.GenerateResponse{Content: "ok"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
|
func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
|
||||||
start := time.Now().UTC()
|
start := time.Now().UTC()
|
||||||
end := start.Add(2 * time.Second)
|
end := start.Add(2 * time.Second)
|
||||||
@@ -184,6 +193,87 @@ func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandlerInlineRefsWorkWithoutArtifactRoot(t *testing.T) {
|
||||||
|
h := newArtifactRootHandler(t, "")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
||||||
|
"prompt_id":"p",
|
||||||
|
"inputs":{"x":{"type":"inline","body":"inline body"}}
|
||||||
|
}`))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerFileRefsWithoutArtifactRootAreRejected(t *testing.T) {
|
||||||
|
h := newArtifactRootHandler(t, "")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
||||||
|
"prompt_id":"p",
|
||||||
|
"inputs":{"x":{"type":"file","uri":"input.txt"}}
|
||||||
|
}`))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assertHTTPErrorCode(t, w, http.StatusBadRequest, "artifact_not_allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerFileRefsUnderArtifactRootWork(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "input.txt"), []byte("allowed"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h := newArtifactRootHandler(t, root)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
||||||
|
"prompt_id":"p",
|
||||||
|
"inputs":{"x":{"type":"file","uri":"input.txt"}}
|
||||||
|
}`))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerFileRefsOutsideArtifactRootAreRejected(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
outside := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("denied"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h := newArtifactRootHandler(t, root)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
uri string
|
||||||
|
}{
|
||||||
|
{name: "relative traversal", uri: filepath.Join("..", filepath.Base(outside), "secret.txt")},
|
||||||
|
{name: "absolute outside root", uri: filepath.Join(outside, "secret.txt")},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
body := fmt.Sprintf(`{
|
||||||
|
"prompt_id":"p",
|
||||||
|
"inputs":{"x":{"type":"file","uri":%q}}
|
||||||
|
}`, tc.uri)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assertHTTPErrorCode(t, w, http.StatusBadRequest, "artifact_not_allowed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandlerPostRunsSuccessUsingPromptDefaultProfile(t *testing.T) {
|
func TestHandlerPostRunsSuccessUsingPromptDefaultProfile(t *testing.T) {
|
||||||
r := &fakeRunner{result: &domain.RunResult{
|
r := &fakeRunner{result: &domain.RunResult{
|
||||||
Artifact: domain.Artifact{Body: []byte("ok")},
|
Artifact: domain.Artifact{Body: []byte("ok")},
|
||||||
@@ -676,3 +766,48 @@ func TestHandlerValidationFailureStillSuccessAndRawOutputOptIn(t *testing.T) {
|
|||||||
func wrap(stage error, cause error) error {
|
func wrap(stage error, cause error) error {
|
||||||
return fmt.Errorf("%w: %w", stage, cause)
|
return fmt.Errorf("%w: %w", stage, cause)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newArtifactRootHandler(t *testing.T, root string) *Handler {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
reader, err := artifact.NewRestrictedCompositeReader(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected restricted artifact reader: %v", err)
|
||||||
|
}
|
||||||
|
runner := usecase.NewRunner(
|
||||||
|
handlerPromptRepo{def: &domain.PromptDefinition{
|
||||||
|
ID: "p",
|
||||||
|
Version: "1",
|
||||||
|
DefaultProfile: "exec",
|
||||||
|
Templates: []domain.PromptMessageTemplate{{Role: "user", Content: "hi"}},
|
||||||
|
OutputFormat: domain.FormatText,
|
||||||
|
Validation: domain.OutputContract{Format: domain.FormatText, ValidationMode: domain.ValidationNone},
|
||||||
|
}},
|
||||||
|
handlerProfileRepo{profile: &domain.ExecutionProfile{
|
||||||
|
ID: "exec",
|
||||||
|
Endpoint: "http://example.invalid/v1",
|
||||||
|
Model: "model",
|
||||||
|
}},
|
||||||
|
reader,
|
||||||
|
handlerRenderer{},
|
||||||
|
handlerLLMClient{},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
return NewHandler(runner)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertHTTPErrorCode(t *testing.T, w *httptest.ResponseRecorder, status int, code string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if w.Code != status {
|
||||||
|
t.Fatalf("expected %d, got %d body=%s", status, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("invalid JSON response: %v", err)
|
||||||
|
}
|
||||||
|
errBody := resp["error"].(map[string]any)
|
||||||
|
if errBody["code"] != code {
|
||||||
|
t.Fatalf("expected code %q, got %#v", code, errBody["code"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ import (
|
|||||||
"mime"
|
"mime"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrUnsupportedRefType = errors.New("unsupported artifact reference type")
|
ErrUnsupportedRefType = errors.New("unsupported artifact reference type")
|
||||||
ErrMissingInlineBody = errors.New("missing body for inline artifact")
|
ErrMissingInlineBody = errors.New("missing body for inline artifact")
|
||||||
ErrMissingFilePath = errors.New("missing file path for file artifact")
|
ErrMissingFilePath = errors.New("missing file path for file artifact")
|
||||||
|
ErrFileNotAllowed = errors.New("file artifact references are not allowed")
|
||||||
|
ErrFileOutsideRoot = errors.New("file artifact path is outside artifact root")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Reader resolves artifact references into actual artifacts.
|
// Reader resolves artifact references into actual artifacts.
|
||||||
@@ -26,7 +29,7 @@ type Reader interface {
|
|||||||
// CompositeReader routes artifact resolution based on the reference type.
|
// CompositeReader routes artifact resolution based on the reference type.
|
||||||
type CompositeReader struct {
|
type CompositeReader struct {
|
||||||
inlineReader *inlineReader
|
inlineReader *inlineReader
|
||||||
fileReader *fileReader
|
fileReader Reader
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCompositeReader() Reader {
|
func NewCompositeReader() Reader {
|
||||||
@@ -36,6 +39,17 @@ func NewCompositeReader() Reader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewRestrictedCompositeReader(root string) (Reader, error) {
|
||||||
|
fileReader, err := newRestrictedFileReader(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &CompositeReader{
|
||||||
|
inlineReader: &inlineReader{},
|
||||||
|
fileReader: fileReader,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CompositeReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
func (c *CompositeReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -89,21 +103,99 @@ func (r *fileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.
|
|||||||
return nil, ErrMissingFilePath
|
return nil, ErrMissingFilePath
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := os.ReadFile(ref.URI)
|
return readFileArtifact(ref.URI)
|
||||||
if err != nil {
|
}
|
||||||
return nil, fmt.Errorf("failed to read file %s: %w", ref.URI, err)
|
|
||||||
|
type deniedFileReader struct{}
|
||||||
|
|
||||||
|
func (r deniedFileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
contentType := mime.TypeByExtension(filepath.Ext(ref.URI))
|
if ref.URI == "" {
|
||||||
|
return nil, ErrMissingFilePath
|
||||||
|
}
|
||||||
|
return nil, ErrFileNotAllowed
|
||||||
|
}
|
||||||
|
|
||||||
|
type restrictedFileReader struct {
|
||||||
|
root string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRestrictedFileReader(root string) (Reader, error) {
|
||||||
|
cleanRoot := strings.TrimSpace(root)
|
||||||
|
if cleanRoot == "" {
|
||||||
|
return deniedFileReader{}, nil
|
||||||
|
}
|
||||||
|
absRoot, err := filepath.Abs(filepath.Clean(cleanRoot))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve artifact root: %w", err)
|
||||||
|
}
|
||||||
|
return &restrictedFileReader{root: absRoot}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *restrictedFileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if ref.URI == "" {
|
||||||
|
return nil, ErrMissingFilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := r.resolve(ref.URI)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return readFileArtifact(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *restrictedFileReader) resolve(rawPath string) (string, error) {
|
||||||
|
cleanPath := filepath.Clean(strings.TrimSpace(rawPath))
|
||||||
|
var candidate string
|
||||||
|
if filepath.IsAbs(cleanPath) {
|
||||||
|
candidate = cleanPath
|
||||||
|
} else {
|
||||||
|
candidate = filepath.Join(r.root, cleanPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
absCandidate, err := filepath.Abs(candidate)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve artifact path: %w", err)
|
||||||
|
}
|
||||||
|
absCandidate = filepath.Clean(absCandidate)
|
||||||
|
|
||||||
|
rel, err := filepath.Rel(r.root, absCandidate)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("compare artifact path to root: %w", err)
|
||||||
|
}
|
||||||
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||||
|
return "", ErrFileOutsideRoot
|
||||||
|
}
|
||||||
|
return absCandidate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFileArtifact(path string) (*domain.Artifact, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := mime.TypeByExtension(filepath.Ext(path))
|
||||||
if contentType == "" {
|
if contentType == "" {
|
||||||
contentType = defaults.ContentTypeTextPlain
|
contentType = defaults.ContentTypeTextPlain
|
||||||
}
|
}
|
||||||
|
|
||||||
return &domain.Artifact{
|
return &domain.Artifact{
|
||||||
Name: filepath.Base(ref.URI),
|
Name: filepath.Base(path),
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
Body: data,
|
Body: data,
|
||||||
URI: ref.URI,
|
URI: path,
|
||||||
Size: int64(len(data)),
|
Size: int64(len(data)),
|
||||||
Hash: fmt.Sprintf("%x", sha256.Sum256(data)),
|
Hash: fmt.Sprintf("%x", sha256.Sum256(data)),
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
||||||
@@ -56,6 +57,81 @@ func TestCompositeReader_Read(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRestrictedCompositeReader(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
root := t.TempDir()
|
||||||
|
outside := t.TempDir()
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "input.txt"), []byte("allowed"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "nested"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("denied"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := NewRestrictedCompositeReader(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected restricted reader construction, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("accepts relative contained path", func(t *testing.T) {
|
||||||
|
art, err := reader.Read(ctx, domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: "nested/../input.txt"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected contained relative path to succeed, got %v", err)
|
||||||
|
}
|
||||||
|
if string(art.Body) != "allowed" {
|
||||||
|
t.Fatalf("unexpected artifact body: %q", string(art.Body))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("accepts absolute contained path", func(t *testing.T) {
|
||||||
|
art, err := reader.Read(ctx, domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: filepath.Join(root, "input.txt")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected contained absolute path to succeed, got %v", err)
|
||||||
|
}
|
||||||
|
if art.Name != "input.txt" {
|
||||||
|
t.Fatalf("unexpected artifact name: %q", art.Name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects relative traversal outside root", func(t *testing.T) {
|
||||||
|
_, err := reader.Read(ctx, domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: filepath.Join("..", filepath.Base(outside), "secret.txt")})
|
||||||
|
if !errors.Is(err, ErrFileOutsideRoot) {
|
||||||
|
t.Fatalf("expected ErrFileOutsideRoot, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects absolute path outside root", func(t *testing.T) {
|
||||||
|
_, err := reader.Read(ctx, domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: filepath.Join(outside, "secret.txt")})
|
||||||
|
if !errors.Is(err, ErrFileOutsideRoot) {
|
||||||
|
t.Fatalf("expected ErrFileOutsideRoot, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestrictedCompositeReaderWithoutRootDeniesFileRefs(t *testing.T) {
|
||||||
|
reader, err := NewRestrictedCompositeReader("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected restricted reader construction, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
art, err := reader.Read(context.Background(), domain.ArtifactRef{Type: domain.ArtifactRefInline, Body: "inline"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected inline ref to work without artifact root, got %v", err)
|
||||||
|
}
|
||||||
|
if string(art.Body) != "inline" {
|
||||||
|
t.Fatalf("unexpected inline body: %q", string(art.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = reader.Read(context.Background(), domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: "input.txt"})
|
||||||
|
if !errors.Is(err, ErrFileNotAllowed) {
|
||||||
|
t.Fatalf("expected ErrFileNotAllowed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFileReader_Read(t *testing.T) {
|
func TestFileReader_Read(t *testing.T) {
|
||||||
content := []byte("test file content")
|
content := []byte("test file content")
|
||||||
tmpFile, err := os.CreateTemp("", "artifact_test_*.txt")
|
tmpFile, err := os.CreateTemp("", "artifact_test_*.txt")
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ type Config struct {
|
|||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Addr string `yaml:"addr"`
|
Addr string `yaml:"addr"`
|
||||||
|
ArtifactRoot string `yaml:"artifact_root"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DefaultsConfig struct {
|
type DefaultsConfig struct {
|
||||||
@@ -53,6 +54,7 @@ type AppSettings struct {
|
|||||||
ProfileDir string
|
ProfileDir string
|
||||||
SchemaDir string
|
SchemaDir string
|
||||||
ServerAddr string
|
ServerAddr string
|
||||||
|
ArtifactRoot string
|
||||||
DefaultRenderFormat renderformat.PreparedRunOutputFormat
|
DefaultRenderFormat renderformat.PreparedRunOutputFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +64,7 @@ type CLIOverrides struct {
|
|||||||
ProfileDir string
|
ProfileDir string
|
||||||
SchemaDir string
|
SchemaDir string
|
||||||
ServerAddr string
|
ServerAddr string
|
||||||
|
ArtifactRoot string
|
||||||
RenderFormat string
|
RenderFormat string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +145,9 @@ func ApplyCLIOverrides(base AppSettings, overrides CLIOverrides) (AppSettings, e
|
|||||||
if v := strings.TrimSpace(overrides.ServerAddr); v != "" {
|
if v := strings.TrimSpace(overrides.ServerAddr); v != "" {
|
||||||
out.ServerAddr = v
|
out.ServerAddr = v
|
||||||
}
|
}
|
||||||
|
if v := strings.TrimSpace(overrides.ArtifactRoot); v != "" {
|
||||||
|
out.ArtifactRoot = filepath.Clean(v)
|
||||||
|
}
|
||||||
if rawFormat := strings.TrimSpace(overrides.RenderFormat); rawFormat != "" {
|
if rawFormat := strings.TrimSpace(overrides.RenderFormat); rawFormat != "" {
|
||||||
parsed, err := renderformat.ParsePreparedRunOutputFormat(rawFormat)
|
parsed, err := renderformat.ParsePreparedRunOutputFormat(rawFormat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -181,6 +187,9 @@ func applyConfig(base AppSettings, cfg Config) (AppSettings, error) {
|
|||||||
if v := strings.TrimSpace(cfg.Server.Addr); v != "" {
|
if v := strings.TrimSpace(cfg.Server.Addr); v != "" {
|
||||||
out.ServerAddr = v
|
out.ServerAddr = v
|
||||||
}
|
}
|
||||||
|
if v := strings.TrimSpace(cfg.Server.ArtifactRoot); v != "" {
|
||||||
|
out.ArtifactRoot = filepath.Clean(v)
|
||||||
|
}
|
||||||
if rawFormat := strings.TrimSpace(cfg.Defaults.RenderFormat); rawFormat != "" {
|
if rawFormat := strings.TrimSpace(cfg.Defaults.RenderFormat); rawFormat != "" {
|
||||||
parsed, err := renderformat.ParsePreparedRunOutputFormat(rawFormat)
|
parsed, err := renderformat.ParsePreparedRunOutputFormat(rawFormat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ profile_dir: ./profiles
|
|||||||
schema_dir: ./schemas
|
schema_dir: ./schemas
|
||||||
server:
|
server:
|
||||||
addr: 127.0.0.1:9090
|
addr: 127.0.0.1:9090
|
||||||
|
artifact_root: ./artifacts
|
||||||
defaults:
|
defaults:
|
||||||
render_format: json
|
render_format: json
|
||||||
`)
|
`)
|
||||||
@@ -113,6 +114,9 @@ defaults:
|
|||||||
if got.ServerAddr != "127.0.0.1:9090" {
|
if got.ServerAddr != "127.0.0.1:9090" {
|
||||||
t.Fatalf("unexpected server.addr: %q", got.ServerAddr)
|
t.Fatalf("unexpected server.addr: %q", got.ServerAddr)
|
||||||
}
|
}
|
||||||
|
if got.ArtifactRoot != filepath.Clean("./artifacts") {
|
||||||
|
t.Fatalf("unexpected server.artifact_root: %q", got.ArtifactRoot)
|
||||||
|
}
|
||||||
if got.DefaultRenderFormat != renderformat.PreparedRunFormatJSON {
|
if got.DefaultRenderFormat != renderformat.PreparedRunFormatJSON {
|
||||||
t.Fatalf("unexpected defaults.render_format: %q", got.DefaultRenderFormat)
|
t.Fatalf("unexpected defaults.render_format: %q", got.DefaultRenderFormat)
|
||||||
}
|
}
|
||||||
@@ -185,6 +189,7 @@ func TestApplyCLIOverridesAppliesPrecedence(t *testing.T) {
|
|||||||
ProfileDir: "/from/config/profiles",
|
ProfileDir: "/from/config/profiles",
|
||||||
SchemaDir: "/from/config/schemas",
|
SchemaDir: "/from/config/schemas",
|
||||||
ServerAddr: ":1234",
|
ServerAddr: ":1234",
|
||||||
|
ArtifactRoot: "/from/config/artifacts",
|
||||||
DefaultRenderFormat: renderformat.PreparedRunFormatJSON,
|
DefaultRenderFormat: renderformat.PreparedRunFormatJSON,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +198,7 @@ func TestApplyCLIOverridesAppliesPrecedence(t *testing.T) {
|
|||||||
ProfileDir: "./profiles-cli",
|
ProfileDir: "./profiles-cli",
|
||||||
SchemaDir: "./schemas-cli",
|
SchemaDir: "./schemas-cli",
|
||||||
ServerAddr: ":8081",
|
ServerAddr: ":8081",
|
||||||
|
ArtifactRoot: "./artifacts-cli",
|
||||||
RenderFormat: "text",
|
RenderFormat: "text",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -211,6 +217,9 @@ func TestApplyCLIOverridesAppliesPrecedence(t *testing.T) {
|
|||||||
if got.ServerAddr != ":8081" {
|
if got.ServerAddr != ":8081" {
|
||||||
t.Fatalf("unexpected server addr: %q", got.ServerAddr)
|
t.Fatalf("unexpected server addr: %q", got.ServerAddr)
|
||||||
}
|
}
|
||||||
|
if got.ArtifactRoot != filepath.Clean("./artifacts-cli") {
|
||||||
|
t.Fatalf("unexpected artifact root: %q", got.ArtifactRoot)
|
||||||
|
}
|
||||||
if got.DefaultRenderFormat != renderformat.PreparedRunFormatText {
|
if got.DefaultRenderFormat != renderformat.PreparedRunFormatText {
|
||||||
t.Fatalf("unexpected render format: %q", got.DefaultRenderFormat)
|
t.Fatalf("unexpected render format: %q", got.DefaultRenderFormat)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ type RunResult struct {
|
|||||||
StartTime time.Time
|
StartTime time.Time
|
||||||
EndTime time.Time
|
EndTime time.Time
|
||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
Error error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreparedRun contains pre-LLM execution state from the prepare/render phase.
|
// PreparedRun contains pre-LLM execution state from the prepare/render phase.
|
||||||
@@ -287,23 +286,3 @@ type ValidationResult struct {
|
|||||||
RepairAttempts int
|
RepairAttempts int
|
||||||
IsValid bool
|
IsValid bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunMetadata contains auditing information for a run.
|
|
||||||
type RunMetadata struct {
|
|
||||||
RunID string
|
|
||||||
PromptID string
|
|
||||||
PromptVersion string
|
|
||||||
PromptHash string
|
|
||||||
RenderedPromptHash string
|
|
||||||
SelectedProfileID string
|
|
||||||
InputHashes map[string]string
|
|
||||||
ModelEndpoint string
|
|
||||||
ModelName string
|
|
||||||
Params ExecutionTarget
|
|
||||||
Timestamp time.Time
|
|
||||||
Duration time.Duration
|
|
||||||
Usage TokenUsage
|
|
||||||
ValidationMode ValidationMode
|
|
||||||
ValidationStatus ValidationStatus
|
|
||||||
RepairAttempts int
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package filecatalog
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -23,7 +25,7 @@ func FindYAMLFiles(ctx context.Context, root string) ([]string, error) {
|
|||||||
if d.IsDir() {
|
if d.IsDir() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !isYAMLFile(d.Name()) {
|
if !IsYAMLFile(d.Name()) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
files = append(files, path)
|
files = append(files, path)
|
||||||
@@ -33,15 +35,64 @@ func FindYAMLFiles(ctx context.Context, root string) ([]string, error) {
|
|||||||
return files, err
|
return files, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// RelativePath computes a clean relative path from root to path.
|
// FindFSYAMLFiles returns sorted paths for .yaml and .yml files under root in fsys.
|
||||||
func RelativePath(root string, path string) string {
|
func FindFSYAMLFiles(ctx context.Context, fsys fs.FS, root string) ([]string, error) {
|
||||||
rel, err := filepath.Rel(root, path)
|
cleanRoot := CleanFSRoot(root)
|
||||||
|
var files []string
|
||||||
|
err := fs.WalkDir(fsys, cleanRoot, func(name string, d fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return filepath.Clean(path)
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !IsYAMLFile(d.Name()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
files = append(files, name)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
sort.Strings(files)
|
||||||
|
return files, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelativePath computes a clean relative path from root to path.
|
||||||
|
func RelativePath(root string, filePath string) string {
|
||||||
|
rel, err := filepath.Rel(root, filePath)
|
||||||
|
if err != nil {
|
||||||
|
return filepath.Clean(filePath)
|
||||||
}
|
}
|
||||||
return filepath.Clean(rel)
|
return filepath.Clean(rel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CleanFSRoot normalizes a root path for use with fs.FS.
|
||||||
|
func CleanFSRoot(root string) string {
|
||||||
|
root = strings.TrimSpace(root)
|
||||||
|
if root == "" || root == "." {
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
return path.Clean(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisplayPath returns name relative to root for messages about fs.FS paths.
|
||||||
|
func DisplayPath(root string, name string) string {
|
||||||
|
cleanRoot := CleanFSRoot(root)
|
||||||
|
cleanName := path.Clean(name)
|
||||||
|
if cleanRoot == "." {
|
||||||
|
return cleanName
|
||||||
|
}
|
||||||
|
prefix := strings.TrimSuffix(cleanRoot, "/") + "/"
|
||||||
|
if strings.HasPrefix(cleanName, prefix) {
|
||||||
|
return strings.TrimPrefix(cleanName, prefix)
|
||||||
|
}
|
||||||
|
return cleanName
|
||||||
|
}
|
||||||
|
|
||||||
// Stem strips .yaml or .yml from a file name.
|
// Stem strips .yaml or .yml from a file name.
|
||||||
func Stem(name string) string {
|
func Stem(name string) string {
|
||||||
name = strings.TrimSuffix(name, ".yaml")
|
name = strings.TrimSuffix(name, ".yaml")
|
||||||
@@ -49,6 +100,6 @@ func Stem(name string) string {
|
|||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
func isYAMLFile(name string) bool {
|
func IsYAMLFile(name string) bool {
|
||||||
return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml")
|
return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFindYAMLFilesNestedSortedAndFiltered(t *testing.T) {
|
func TestFindYAMLFilesNestedSortedAndFiltered(t *testing.T) {
|
||||||
@@ -43,6 +44,42 @@ func TestFindYAMLFilesHonorsContextCancellation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFindFSYAMLFilesNestedSortedAndFiltered(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"prompts/z/prompt.yml": &fstest.MapFile{Data: []byte("id: z")},
|
||||||
|
"prompts/a/profile.yaml": &fstest.MapFile{Data: []byte("id: a")},
|
||||||
|
"prompts/a/ignore.txt": &fstest.MapFile{Data: []byte("not yaml")},
|
||||||
|
"prompts/b/ignore.yaml.bak": &fstest.MapFile{Data: []byte("not yaml")},
|
||||||
|
"other/ignored.yaml": &fstest.MapFile{Data: []byte("id: ignored")},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := FindFSYAMLFiles(context.Background(), fsys, " prompts ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
"prompts/a/profile.yaml",
|
||||||
|
"prompts/z/prompt.yml",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("expected sorted YAML files %v, got %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindFSYAMLFilesHonorsContextCancellation(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"one.yaml": &fstest.MapFile{Data: []byte("id: one")},
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := FindFSYAMLFiles(ctx, fsys, ".")
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("expected context.Canceled, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRelativePathNested(t *testing.T) {
|
func TestRelativePathNested(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
path := filepath.Join(root, "nested", "profiles", "local.yaml")
|
path := filepath.Join(root, "nested", "profiles", "local.yaml")
|
||||||
@@ -53,6 +90,47 @@ func TestRelativePathNested(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCleanFSRoot(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
root string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "empty", root: "", want: "."},
|
||||||
|
{name: "dot", root: ".", want: "."},
|
||||||
|
{name: "trimmed", root: " prompts/../profiles ", want: "profiles"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := CleanFSRoot(tc.root); got != tc.want {
|
||||||
|
t.Fatalf("expected %q, got %q", tc.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
root string
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "root dot", root: ".", path: "profiles/local.yaml", want: "profiles/local.yaml"},
|
||||||
|
{name: "nested root", root: "profiles", path: "profiles/local.yaml", want: "local.yaml"},
|
||||||
|
{name: "outside root", root: "profiles", path: "other/local.yaml", want: "other/local.yaml"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := DisplayPath(tc.root, tc.path); got != tc.want {
|
||||||
|
t.Fatalf("expected %q, got %q", tc.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStemStripsYAMLExtensions(t *testing.T) {
|
func TestStemStripsYAMLExtensions(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -73,6 +151,27 @@ func TestStemStripsYAMLExtensions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsYAMLFile(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "yaml", in: "prompt.yaml", want: true},
|
||||||
|
{name: "yml", in: "profile.yml", want: true},
|
||||||
|
{name: "backup", in: "profile.yaml.bak", want: false},
|
||||||
|
{name: "uppercase", in: "profile.YAML", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := IsYAMLFile(tc.in); got != tc.want {
|
||||||
|
t.Fatalf("expected %v, got %v", tc.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustWriteFile(t *testing.T, path string, content string) {
|
func mustWriteFile(t *testing.T, path string, content string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/scriptorium/internal/filecatalog"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -79,7 +80,7 @@ func loadProfile(ctx context.Context, fsys fs.FS, root string, id string) (*doma
|
|||||||
return nil, fmt.Errorf("failed to read profile directory: filesystem is nil")
|
return nil, fmt.Errorf("failed to read profile directory: filesystem is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
files, err := findProfileYAMLFiles(ctx, fsys, root)
|
files, err := filecatalog.FindFSYAMLFiles(ctx, fsys, root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read profile directory: %w", err)
|
return nil, fmt.Errorf("failed to read profile directory: %w", err)
|
||||||
}
|
}
|
||||||
@@ -92,8 +93,8 @@ func loadProfile(ctx context.Context, fsys fs.FS, root string, id string) (*doma
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
relPath := displayPath(root, fullPath)
|
relPath := filecatalog.DisplayPath(root, fullPath)
|
||||||
fileMatch := profileFileStem(path.Base(fullPath)) == id
|
fileMatch := filecatalog.Stem(path.Base(fullPath)) == id
|
||||||
data, err := fs.ReadFile(fsys, fullPath)
|
data, err := fs.ReadFile(fsys, fullPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read profile file %s: %w", relPath, err)
|
return nil, fmt.Errorf("failed to read profile file %s: %w", relPath, err)
|
||||||
@@ -147,61 +148,6 @@ func loadProfile(ctx context.Context, fsys fs.FS, root string, id string) (*doma
|
|||||||
return nil, ErrProfileNotFound
|
return nil, ErrProfileNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func findProfileYAMLFiles(ctx context.Context, fsys fs.FS, root string) ([]string, error) {
|
|
||||||
cleanRoot := cleanFSRoot(root)
|
|
||||||
var files []string
|
|
||||||
err := fs.WalkDir(fsys, cleanRoot, func(name string, d fs.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
if d.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !isProfileYAMLFile(d.Name()) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
files = append(files, name)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return files, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func cleanFSRoot(root string) string {
|
|
||||||
root = strings.TrimSpace(root)
|
|
||||||
if root == "" || root == "." {
|
|
||||||
return "."
|
|
||||||
}
|
|
||||||
return path.Clean(root)
|
|
||||||
}
|
|
||||||
|
|
||||||
func displayPath(root string, name string) string {
|
|
||||||
cleanRoot := cleanFSRoot(root)
|
|
||||||
cleanName := path.Clean(name)
|
|
||||||
if cleanRoot == "." {
|
|
||||||
return cleanName
|
|
||||||
}
|
|
||||||
prefix := strings.TrimSuffix(cleanRoot, "/") + "/"
|
|
||||||
if strings.HasPrefix(cleanName, prefix) {
|
|
||||||
return strings.TrimPrefix(cleanName, prefix)
|
|
||||||
}
|
|
||||||
return cleanName
|
|
||||||
}
|
|
||||||
|
|
||||||
func profileFileStem(name string) string {
|
|
||||||
name = strings.TrimSuffix(name, ".yaml")
|
|
||||||
name = strings.TrimSuffix(name, ".yml")
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
func isProfileYAMLFile(name string) bool {
|
|
||||||
return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml")
|
|
||||||
}
|
|
||||||
|
|
||||||
type profileMatch struct {
|
type profileMatch struct {
|
||||||
profile *domain.ExecutionProfile
|
profile *domain.ExecutionProfile
|
||||||
path string
|
path string
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, id strin
|
|||||||
return nil, fmt.Errorf("failed to read prompt definition directory: filesystem is nil")
|
return nil, fmt.Errorf("failed to read prompt definition directory: filesystem is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
files, err := findPromptDefinitionYAMLFiles(ctx, fsys, root)
|
files, err := filecatalog.FindFSYAMLFiles(ctx, fsys, root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read prompt definition directory: %w", err)
|
return nil, fmt.Errorf("failed to read prompt definition directory: %w", err)
|
||||||
}
|
}
|
||||||
@@ -202,7 +202,7 @@ func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, id strin
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
relPath := displayPath(root, fullPath)
|
relPath := filecatalog.DisplayPath(root, fullPath)
|
||||||
fileMatch := filecatalog.Stem(path.Base(fullPath)) == id
|
fileMatch := filecatalog.Stem(path.Base(fullPath)) == id
|
||||||
data, err := fs.ReadFile(fsys, fullPath)
|
data, err := fs.ReadFile(fsys, fullPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -258,55 +258,6 @@ func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, id strin
|
|||||||
return nil, ErrPromptDefinitionNotFound
|
return nil, ErrPromptDefinitionNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func findPromptDefinitionYAMLFiles(ctx context.Context, fsys fs.FS, root string) ([]string, error) {
|
|
||||||
cleanRoot := cleanFSRoot(root)
|
|
||||||
var files []string
|
|
||||||
err := fs.WalkDir(fsys, cleanRoot, func(name string, d fs.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
if d.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !isPromptDefinitionYAMLFile(d.Name()) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
files = append(files, name)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return files, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func cleanFSRoot(root string) string {
|
|
||||||
root = strings.TrimSpace(root)
|
|
||||||
if root == "" || root == "." {
|
|
||||||
return "."
|
|
||||||
}
|
|
||||||
return path.Clean(root)
|
|
||||||
}
|
|
||||||
|
|
||||||
func displayPath(root string, name string) string {
|
|
||||||
cleanRoot := cleanFSRoot(root)
|
|
||||||
cleanName := path.Clean(name)
|
|
||||||
if cleanRoot == "." {
|
|
||||||
return cleanName
|
|
||||||
}
|
|
||||||
prefix := strings.TrimSuffix(cleanRoot, "/") + "/"
|
|
||||||
if strings.HasPrefix(cleanName, prefix) {
|
|
||||||
return strings.TrimPrefix(cleanName, prefix)
|
|
||||||
}
|
|
||||||
return cleanName
|
|
||||||
}
|
|
||||||
|
|
||||||
func isPromptDefinitionYAMLFile(name string) bool {
|
|
||||||
return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml")
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodePromptDefinition(data []byte) (*promptDefinitionFile, error) {
|
func decodePromptDefinition(data []byte) (*promptDefinitionFile, error) {
|
||||||
var raw promptDefinitionFile
|
var raw promptDefinitionFile
|
||||||
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ func NewFSValidator(fsys fs.FS, root string) Validator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *StandardValidator) Validate(ctx context.Context, artifact *domain.Artifact, contract domain.OutputContract) (domain.ValidationResult, error) {
|
func (v *StandardValidator) Validate(ctx context.Context, artifact *domain.Artifact, contract domain.OutputContract) (domain.ValidationResult, error) {
|
||||||
|
return validateArtifact(ctx, artifact, contract, v.validateJSONSchema)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *FSValidator) Validate(ctx context.Context, artifact *domain.Artifact, contract domain.OutputContract) (domain.ValidationResult, error) {
|
||||||
|
return validateArtifact(ctx, artifact, contract, v.validateJSONSchema)
|
||||||
|
}
|
||||||
|
|
||||||
|
type schemaValidatorFunc func(instance any, schemaPath string) ([]string, error)
|
||||||
|
|
||||||
|
func validateArtifact(ctx context.Context, artifact *domain.Artifact, contract domain.OutputContract, validateSchema schemaValidatorFunc) (domain.ValidationResult, error) {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return domain.ValidationResult{}, ctx.Err()
|
return domain.ValidationResult{}, ctx.Err()
|
||||||
@@ -85,21 +95,14 @@ func (v *StandardValidator) Validate(ctx context.Context, artifact *domain.Artif
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
schemaPath, err := v.resolveSchemaPath(contract.SchemaPath)
|
validationErrors, err := validateSchema(instance, contract.SchemaPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ValidationResult{}, err
|
return domain.ValidationResult{}, err
|
||||||
}
|
}
|
||||||
|
if len(validationErrors) > 0 {
|
||||||
compiler := jsonschema.NewCompiler()
|
|
||||||
schema, err := compiler.Compile(schemaPath)
|
|
||||||
if err != nil {
|
|
||||||
return domain.ValidationResult{}, fmt.Errorf("failed to compile JSON schema %q: %w", schemaPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := schema.Validate(instance); err != nil {
|
|
||||||
res.Status = domain.ValidationFailed
|
res.Status = domain.ValidationFailed
|
||||||
res.IsValid = false
|
res.IsValid = false
|
||||||
res.Errors = []string{fmt.Sprintf("json schema validation failed: %v", err)}
|
res.Errors = validationErrors
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,86 +114,44 @@ func (v *StandardValidator) Validate(ctx context.Context, artifact *domain.Artif
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *FSValidator) Validate(ctx context.Context, artifact *domain.Artifact, contract domain.OutputContract) (domain.ValidationResult, error) {
|
func (v *StandardValidator) validateJSONSchema(instance any, schemaPath string) ([]string, error) {
|
||||||
select {
|
resolvedSchemaPath, err := v.resolveSchemaPath(schemaPath)
|
||||||
case <-ctx.Done():
|
|
||||||
return domain.ValidationResult{}, ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
res := domain.ValidationResult{
|
|
||||||
Mode: contract.ValidationMode,
|
|
||||||
SchemaPath: contract.SchemaPath,
|
|
||||||
RepairAttempts: contract.RepairAttempts,
|
|
||||||
}
|
|
||||||
|
|
||||||
if artifact == nil {
|
|
||||||
return domain.ValidationResult{}, errors.New("artifact is required for validation")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch contract.ValidationMode {
|
|
||||||
case domain.ValidationNone:
|
|
||||||
res.Status = domain.ValidationSkipped
|
|
||||||
res.IsValid = true
|
|
||||||
return res, nil
|
|
||||||
case domain.ValidationBasic:
|
|
||||||
if strings.TrimSpace(string(artifact.Body)) == "" {
|
|
||||||
res.Status = domain.ValidationFailed
|
|
||||||
res.IsValid = false
|
|
||||||
res.Errors = []string{"output is empty"}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
res.Status = domain.ValidationPassed
|
|
||||||
res.IsValid = true
|
|
||||||
return res, nil
|
|
||||||
case domain.ValidationJSON:
|
|
||||||
_, jsonErr := parseJSON(artifact.Body)
|
|
||||||
if jsonErr != nil {
|
|
||||||
res.Status = domain.ValidationFailed
|
|
||||||
res.IsValid = false
|
|
||||||
res.Errors = []string{fmt.Sprintf("invalid JSON: %v", jsonErr)}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
res.Status = domain.ValidationPassed
|
|
||||||
res.IsValid = true
|
|
||||||
return res, nil
|
|
||||||
case domain.ValidationJSONSchema:
|
|
||||||
instance, jsonErr := parseJSON(artifact.Body)
|
|
||||||
if jsonErr != nil {
|
|
||||||
res.Status = domain.ValidationFailed
|
|
||||||
res.IsValid = false
|
|
||||||
res.Errors = []string{fmt.Sprintf("invalid JSON: %v", jsonErr)}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
schemaName, schemaDoc, err := v.loadSchemaDocument(contract.SchemaPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ValidationResult{}, err
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
compiler := jsonschema.NewCompiler()
|
||||||
|
schema, err := compiler.Compile(resolvedSchemaPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to compile JSON schema %q: %w", resolvedSchemaPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := schema.Validate(instance); err != nil {
|
||||||
|
return []string{fmt.Sprintf("json schema validation failed: %v", err)}, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *FSValidator) validateJSONSchema(instance any, schemaPath string) ([]string, error) {
|
||||||
|
schemaName, schemaDoc, err := v.loadSchemaDocument(schemaPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
resourceURL := fsSchemaResourceURL(schemaName)
|
resourceURL := fsSchemaResourceURL(schemaName)
|
||||||
compiler := jsonschema.NewCompiler()
|
compiler := jsonschema.NewCompiler()
|
||||||
if err := compiler.AddResource(resourceURL, schemaDoc); err != nil {
|
if err := compiler.AddResource(resourceURL, schemaDoc); err != nil {
|
||||||
return domain.ValidationResult{}, fmt.Errorf("failed to register JSON schema %q: %w", schemaName, err)
|
return nil, fmt.Errorf("failed to register JSON schema %q: %w", schemaName, err)
|
||||||
}
|
}
|
||||||
schema, err := compiler.Compile(resourceURL)
|
schema, err := compiler.Compile(resourceURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ValidationResult{}, fmt.Errorf("failed to compile JSON schema %q: %w", schemaName, err)
|
return nil, fmt.Errorf("failed to compile JSON schema %q: %w", schemaName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := schema.Validate(instance); err != nil {
|
if err := schema.Validate(instance); err != nil {
|
||||||
res.Status = domain.ValidationFailed
|
return []string{fmt.Sprintf("json schema validation failed: %v", err)}, nil
|
||||||
res.IsValid = false
|
|
||||||
res.Errors = []string{fmt.Sprintf("json schema validation failed: %v", err)}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
res.Status = domain.ValidationPassed
|
|
||||||
res.IsValid = true
|
|
||||||
return res, nil
|
|
||||||
default:
|
|
||||||
return domain.ValidationResult{}, fmt.Errorf("unsupported validation mode: %q", contract.ValidationMode)
|
|
||||||
}
|
}
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseJSON(body []byte) (any, error) {
|
func parseJSON(body []byte) (any, error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user