diff --git a/README.md b/README.md index 0e08a2a..f9b4a81 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,35 @@ curl -fsSL https://raw.githubusercontent.com/recursive-oss/rec/main/install.sh | This installs `rec` in `~/.local/bin` and adds it to your PATH for new terminals. All other data lives under `~/.local/share/rec`. +### Native AMD/HIP development package + +The native HIP target currently requires Linux/amd64, a ROCm installation, +and a locally persisted, verified Flint checkpoint. It does not download or +copy the checkpoint. Build `flint-serve`, then stage and package it with: + +```bash +python3 packaging/build-flint-runtime.py \ + --flint-serve /path/to/flint-serve \ + --output /path/to/flint-runtime +python3 packaging/package.py \ + --runtime-dir /path/to/flint-runtime \ + --manifest packaging/flint-int4-hip.json \ + --version VERSION \ + --output /path/to/rec +``` + +Start the packaged binary against the persistent checkpoint directory: + +```bash +/path/to/rec serve flint \ + --model-dir /path/to/flint-int4 \ + --context 4096 +``` + +The HIP target currently serves the OpenAI-compatible endpoint. Anthropic +Messages support remains specific to the MLX runtime until native support is +implemented. + ## Usage @@ -99,17 +128,19 @@ sessions; remove those variables to keep yours. To send a request directly to the OpenAI-compatible API: ```bash -curl -fsS http://127.0.0.1:11500/v1/chat/completions \ - -H "Authorization: Bearer $(rec auth token)" \ +read -r REC_TOKEN < <(rec auth token) +printf 'header = "Authorization: Bearer %s"\n' "$REC_TOKEN" | \ +curl --config - -fsS http://127.0.0.1:11500/v1/chat/completions \ -H 'Content-Type: application/json' \ -d '{"model":"flint","messages":[{"role":"user","content":"Say hello."}],"max_tokens":128}' +unset REC_TOKEN ``` The response contains generated text in `choices`. For other clients, use base URL `http://127.0.0.1:11500/v1`, model `flint`, and the API key from `rec auth token`. -The Anthropic-compatible API is available at +For the MLX runtime, the Anthropic-compatible API is available at `http://127.0.0.1:11500/v1/messages`. Point `ANTHROPIC_BASE_URL` at `http://127.0.0.1:11500` and send the token from `rec auth token` as a bearer token or as `x-api-key`. The model is `flint`. diff --git a/cli/cli.go b/cli/cli.go index 49e42a6..fc0062f 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -13,6 +13,19 @@ import ( "syscall" ) +func validateModelSource(command string, m Manifest, modelDir string) error { + if m.Engine != "flint-native" { + return nil + } + if command == "pull" { + return fmt.Errorf("native Flint checkpoints are local-only; rec pull is unavailable") + } + if command == "serve" && modelDir == "" { + return fmt.Errorf("native Flint requires --model-dir DIR") + } + return nil +} + func run(args []string) error { background := len(args) > 0 && args[0] == "_background" if background { @@ -92,10 +105,12 @@ func run(args []string) error { manifest := flags.String("manifest", "", "") runtimeDir := flags.String("runtime-dir", "", "") var contextTokens int + var modelDir string port := 11500 if command == "serve" { flags.IntVar(&contextTokens, "context", 0, "") flags.IntVar(&port, "port", port, "") + flags.StringVar(&modelDir, "model-dir", "", "") } flags.SetOutput(io.Discard) if e := flags.Parse(rest); e != nil { @@ -127,6 +142,12 @@ func run(args []string) error { if *runtimeDir != "" && *manifest == "" { return fmt.Errorf("--runtime-dir requires --manifest") } + if modelDir != "" && m.Engine != "flint-native" { + return fmt.Errorf("--model-dir is supported only by the native Flint engine") + } + if e := validateModelSource(command, m, modelDir); e != nil { + return e + } if m.Development { newScreen(os.Stderr).step(warningStyle.Render("!"), "Development bundle: this is not a qualified Flint release.", "") } @@ -139,7 +160,7 @@ func run(args []string) error { } return e } - return serve(ctx, p, m, *runtimeDir, port, os.Stderr, background) + return serve(ctx, p, m, *runtimeDir, modelDir, port, os.Stderr, background) } func doctor(p Paths, m Manifest, manifestErr error) error { @@ -166,10 +187,10 @@ func doctor(p Paths, m Manifest, manifestErr error) error { return manifestErr } if err := preflight(m); err != nil { - ui.failed("This Mac does not meet the requirements", "") + ui.failed("This system does not meet the requirements", "") return err } - ui.succeeded("This Mac meets the requirements", "") + ui.succeeded("This system meets the requirements", "") if !modelFilesPresent(p, m) { ui.step(warningStyle.Render("!"), displayName(m.Model)+" is not downloaded", "run rec pull") return nil diff --git a/cli/context_test.go b/cli/context_test.go index 1d7a4b8..7d39643 100644 --- a/cli/context_test.go +++ b/cli/context_test.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "os" + "reflect" "testing" ) @@ -97,6 +98,26 @@ func TestContextReachesEngine(t *testing.T) { } } +func TestNativeHIPLaunchContract(t *testing.T) { + m := releaseManifest(t) + m.Engine, m.OS, m.Arch, m.Backend = "flint-native", "linux", "amd64", "hip" + m.Context = 262144 + executable, args, env := launchCommand(Paths{Config: "/config"}, m, "/runtime", "/model/config.json", 11500) + wantArgs := []string{ + "--model", "/model", "--checkpoint-dir", "/model", + "--context-capacity", "262144", "--device-budget", "32592542688", + "--bind", "127.0.0.1", "--port", "11500", "--queue-limit", "8", + "--api-key-file", "/config/api-key", "--served-model", "flint", + "--enforce-tool-schemas", + } + if executable != "/runtime/flint-serve" || !reflect.DeepEqual(args, wantArgs) { + t.Fatalf("native launch mismatch\nexecutable: %q\nargs: %#v", executable, args) + } + if len(env) != 0 { + t.Fatalf("native launch must not place credentials in the environment: %#v", env) + } +} + func releaseManifest(t *testing.T) Manifest { t.Helper() b, err := os.ReadFile("../packaging/flint-int4-mlx.json") diff --git a/cli/download.go b/cli/download.go index 7642544..d59ff81 100644 --- a/cli/download.go +++ b/cli/download.go @@ -137,6 +137,37 @@ func ensureModel(ctx context.Context, p Paths, m Manifest, out io.Writer) (strin return ensureModelWithClient(ctx, p, m, out, client) } +// verifyLocalModel hashes the manifest-declared files in their existing +// directory. It never copies or downloads model bytes; the native runtime +// independently revalidates the pinned checkpoint before GPU admission. +func verifyLocalModel(root string, m Manifest, out io.Writer) (string, error) { + abs, err := filepath.Abs(root) + if err != nil { + return "", err + } + before, err := os.Lstat(abs) + if err != nil || !before.IsDir() || before.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("local model directory must be a real directory") + } + var total int64 + for _, f := range m.Files { + total += f.Size + } + progress := newTransfer(out, "Verifying "+displayName(m.Model), "Verified "+displayName(m.Model), 0, total) + for _, f := range m.Files { + if err := verifyFileInto(filepath.Join(abs, f.Name), f, progress); err != nil { + progress.fail(err.Error()) + return "", err + } + } + after, err := os.Lstat(abs) + if err != nil || !os.SameFile(before, after) { + return "", fmt.Errorf("local model directory changed during verification") + } + progress.succeed() + return filepath.Join(abs, m.Entry), nil +} + func ensureModelWithClient(ctx context.Context, p Paths, m Manifest, out io.Writer, client *http.Client) (string, error) { id := modelDigest(m) root := filepath.Join(p.Data, "models", id) diff --git a/cli/engine.go b/cli/engine.go index aaff247..bc95507 100644 --- a/cli/engine.go +++ b/cli/engine.go @@ -7,6 +7,21 @@ import ( ) func launchCommand(p Paths, m Manifest, runtimeDir, model string, port int) (string, []string, []string) { + if m.Engine == "flint-native" { + const base = uint64(25074426720) + const perToken = uint64(14 * 2048) + const logits = uint64(150144 * 13) + budget := base + uint64(m.Context-1)*perToken + logits + modelDir := filepath.Dir(model) + return filepath.Join(runtimeDir, "flint-serve"), []string{ + "--model", modelDir, "--checkpoint-dir", modelDir, + "--context-capacity", strconv.Itoa(m.Context), + "--device-budget", strconv.FormatUint(budget, 10), + "--bind", "127.0.0.1", "--port", strconv.Itoa(port), + "--queue-limit", "8", "--api-key-file", filepath.Join(p.Config, "api-key"), + "--served-model", m.Model, "--enforce-tool-schemas", + }, nil + } return filepath.Join(runtimeDir, runtimeServer), []string{ "serve", m.Model, "--model-dir", filepath.Dir(model), "--manifest", model, "--port", strconv.Itoa(port), "--context-limit", strconv.Itoa(m.Context), diff --git a/cli/help.go b/cli/help.go index 2bc4474..9b1c723 100644 --- a/cli/help.go +++ b/cli/help.go @@ -27,6 +27,7 @@ var commands = []commandHelp{ {"serve", "[MODEL]", "Start the model server in this terminal", append([][2]string{ {"--context TOKENS", "Total input and output tokens (default: set by the release)"}, {"--port PORT", "Local port (default: 11500)"}, + {"--model-dir DIR", "Verify and use an existing local native checkpoint in place"}, }, developmentOptions...)}, {"status", "", "Show whether the server is running", nil}, {"stop", "", "Stop the server", nil}, diff --git a/cli/launch.go b/cli/launch.go index 57b6e59..7081f01 100644 --- a/cli/launch.go +++ b/cli/launch.go @@ -167,6 +167,13 @@ func openingClient(name string, s Status) { newScreen(os.Stderr).step(accentStyle.Render("→"), "Opening "+name, fmt.Sprintf("Flint at %s, %s tokens", s.Endpoint, formatTokens(s.Context))) } +func validateLaunchAutostart(m Manifest) error { + if m.Engine == "flint-native" { + return fmt.Errorf("native Flint launch requires an existing local server; start it with rec serve flint --model-dir DIR") + } + return nil +} + func openCodeArgs(args []string) []string { // V2's TUI selects its model from config; --model is a run-only flag. // A private OpenCode server receives this process's Rec config and API key, @@ -217,6 +224,9 @@ func launchReady(ctx context.Context, p Paths, out io.Writer) (Status, error) { if e != nil { return s, e } + if e = validateLaunchAutostart(m); e != nil { + return s, e + } if !modelFilesPresent(p, m) { if e = preflight(m); e != nil { return s, e diff --git a/cli/manifest.go b/cli/manifest.go index c193378..dee8c94 100644 --- a/cli/manifest.go +++ b/cli/manifest.go @@ -31,21 +31,24 @@ type File struct { } type Manifest struct { - Engine string `json:"engine"` - Schema int `json:"schema"` - Model string `json:"model"` - Revision string `json:"revision"` - Development bool `json:"development"` - OS string `json:"os"` - Arch string `json:"arch"` - Backend string `json:"backend"` - Context int `json:"context"` - MaxContext int `json:"max_context,omitempty"` - MinRAM int64 `json:"min_ram_bytes"` - Files []File `json:"files"` - Entry string `json:"entry"` - RuntimeSHA string `json:"runtime_sha256"` - RuntimeFiles []File `json:"runtime_files"` + Engine string `json:"engine"` + Schema int `json:"schema"` + Model string `json:"model"` + Revision string `json:"revision"` + Development bool `json:"development"` + OS string `json:"os"` + Arch string `json:"arch"` + Backend string `json:"backend"` + Context int `json:"context"` + MaxContext int `json:"max_context,omitempty"` + MinRAM int64 `json:"min_ram_bytes"` + Files []File `json:"files"` + Entry string `json:"entry"` + RuntimeSHA string `json:"runtime_sha256"` + RuntimeFiles []File `json:"runtime_files"` + RuntimeBinarySHA string `json:"runtime_binary_sha256,omitempty"` + RuntimeSourceCommit string `json:"runtime_source_commit,omitempty"` + RuntimeSourceTree string `json:"runtime_source_tree,omitempty"` } func validHash(s string) bool { @@ -68,6 +71,19 @@ func safeRuntimeName(s string) bool { } const runtimeServer = "rec-mlx" +const nativeFlintRevision = "3b93f9053b530488405ae9fca51d4900d6ce21f0" +const nativeFlintMaxContext = 262144 + +func runtimeServerFor(m Manifest) (string, error) { + switch { + case m.Engine == "mlx" && m.OS == "darwin" && m.Arch == "arm64" && m.Backend == "metal": + return runtimeServer, nil + case m.Engine == "flint-native" && m.OS == "linux" && m.Arch == "amd64" && m.Backend == "hip": + return "flint-serve", nil + default: + return "", fmt.Errorf("unsupported engine or target") + } +} func decodeManifest(data []byte) (Manifest, error) { var m Manifest @@ -80,13 +96,23 @@ func decodeManifest(data []byte) (Manifest, error) { if d.Decode(&extra) != io.EOF { return m, fmt.Errorf("trailing manifest data") } - // Each engine is one target. Adding an engine adds its own case here. - if m.Engine != "mlx" || m.OS != "darwin" || m.Arch != "arm64" || m.Backend != "metal" { - return m, fmt.Errorf("unsupported engine or target: rec runs the MLX engine on Apple Silicon") + // Each engine is one closed target tuple. + serverName, err := runtimeServerFor(m) + if err != nil { + return m, err } if m.MaxContext < 0 || (m.MaxContext > 0 && m.Context > m.MaxContext) { return m, fmt.Errorf("invalid maximum context") } + if m.Engine == "flint-native" && + (m.Revision != nativeFlintRevision || m.MaxContext != nativeFlintMaxContext || m.Context > nativeFlintMaxContext) { + return m, fmt.Errorf("native Flint revision or context does not match the runtime contract") + } + if m.Engine == "flint-native" && + (!validHash(m.RuntimeBinarySHA) || !regexp.MustCompile(`^[0-9a-f]{40}$`).MatchString(m.RuntimeSourceCommit) || + !regexp.MustCompile(`^[0-9a-f]{40}$`).MatchString(m.RuntimeSourceTree)) { + return m, fmt.Errorf("native Flint trusted runtime pins are missing") + } if m.Schema != 1 || !safeName(m.Model) || m.Revision == "" || m.Context < 256 || m.MinRAM <= 0 || len(m.Files) == 0 { return m, fmt.Errorf("incomplete model descriptor") } @@ -124,12 +150,12 @@ func decodeManifest(data []byte) (Manifest, error) { } server := false for _, f := range m.RuntimeFiles { - if f.Name == runtimeServer && f.Executable { + if f.Name == serverName && f.Executable { server = true } } if !server { - return m, fmt.Errorf("runtime requires executable %s", runtimeServer) + return m, fmt.Errorf("runtime requires executable %s", serverName) } return m, nil } diff --git a/cli/platform.go b/cli/platform.go index 3d50b83..62de79d 100644 --- a/cli/platform.go +++ b/cli/platform.go @@ -3,13 +3,51 @@ package main import ( "context" "fmt" + "os" "os/exec" + "path/filepath" "runtime" "strconv" "strings" "time" ) +var nativeHIPProbe = func() error { + return nativeHIPHardware("/dev/kfd", "/sys/class/kfd/kfd/topology/nodes/*/properties") +} + +func nativeHIPHardware(kfdPath, topologyPattern string) error { + kfd, err := os.OpenFile(kfdPath, os.O_RDWR, 0) + if err != nil { + return fmt.Errorf("native Flint requires read/write access to /dev/kfd: %w", err) + } + kfd.Close() + properties, err := filepath.Glob(topologyPattern) + if err != nil || len(properties) == 0 { + return fmt.Errorf("cannot inspect AMD GPU topology") + } + for _, path := range properties { + data, readErr := os.ReadFile(path) + if readErr != nil { + continue + } + values := map[string]string{} + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 { + values[fields[0]] = fields[1] + } + } + if values["gfx_target_version"] == "110501" && values["wave_front_size"] == "32" { + simd, parseErr := strconv.ParseUint(values["simd_count"], 10, 32) + if parseErr == nil && simd > 0 { + return nil + } + } + } + return fmt.Errorf("native Flint requires an AMD gfx1151 GPU") +} + func command(name string, args ...string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -18,6 +56,28 @@ func command(name string, args ...string) (string, error) { } func preflight(m Manifest) error { + if m.Engine == "flint-native" { + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + return fmt.Errorf("native Flint requires Linux/amd64") + } + data, err := os.ReadFile("/proc/meminfo") + if err != nil { + return err + } + fields := strings.Fields(string(data)) + if len(fields) < 2 || fields[0] != "MemTotal:" { + return fmt.Errorf("cannot determine host memory") + } + kib, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil || kib <= 0 || kib > (1<<63-1)/1024 { + return fmt.Errorf("cannot determine host memory") + } + ram := kib * 1024 + if ram < m.MinRAM { + return fmt.Errorf("this model requires %d GiB of memory; detected %d GiB", m.MinRAM>>30, ram>>30) + } + return nativeHIPProbe() + } if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { return fmt.Errorf("rec requires an Apple Silicon Mac") } diff --git a/cli/rec_test.go b/cli/rec_test.go index 1caade0..511f39e 100644 --- a/cli/rec_test.go +++ b/cli/rec_test.go @@ -14,6 +14,7 @@ import ( "net/url" "os" "path/filepath" + "runtime" "strings" "sync" "testing" @@ -355,6 +356,70 @@ func TestManifestSchemaAndPaths(t *testing.T) { } } +func TestManifestAcceptsPinnedNativeHIPTarget(t *testing.T) { + m := Manifest{ + Engine: "flint-native", Schema: 1, Model: "flint", + Revision: "3b93f9053b530488405ae9fca51d4900d6ce21f0", + OS: "linux", Arch: "amd64", Backend: "hip", Context: 262144, MaxContext: 262144, + MinRAM: 1, Entry: "config.json", + Files: []File{{Name: "config.json", Size: 1, SHA256: digest([]byte("x")), URL: "https://example.com/config.json"}}, + RuntimeSHA: digest([]byte("runtime")), + RuntimeFiles: []File{{Name: "flint-serve", Size: 1, SHA256: digest([]byte("r")), Executable: true}}, + RuntimeBinarySHA: digest([]byte("r")), + RuntimeSourceCommit: strings.Repeat("a", 40), + RuntimeSourceTree: strings.Repeat("b", 40), + } + got, err := decodeManifest(mustJSON(t, m)) + if err != nil { + t.Fatal(err) + } + if got.Engine != "flint-native" || got.Backend != "hip" { + t.Fatalf("native HIP target identity lost: %+v", got) + } + for name, change := range map[string]func(*Manifest){ + "revision": func(value *Manifest) { value.Revision = "unpinned" }, + "max context": func(value *Manifest) { value.MaxContext = 262145 }, + "context": func(value *Manifest) { value.Context, value.MaxContext = 262145, 262145 }, + } { + t.Run(name, func(t *testing.T) { + bad := m + change(&bad) + if _, err := decodeManifest(mustJSON(t, bad)); err == nil { + t.Fatal("unpinned native contract accepted") + } + }) + } +} + +func TestVerifyLocalModelUsesVerifiedFilesInPlace(t *testing.T) { + root := t.TempDir() + data := []byte("pinned checkpoint bytes") + entry := filepath.Join(root, "config.json") + if err := os.WriteFile(entry, data, 0600); err != nil { + t.Fatal(err) + } + m := Manifest{Model: "flint", Entry: "config.json", Files: []File{{ + Name: "config.json", Size: int64(len(data)), SHA256: digest(data), + }}} + got, err := verifyLocalModel(root, m, io.Discard) + if err != nil || got != entry { + t.Fatalf("verified local model not returned in place: %q %v", got, err) + } + if err := os.WriteFile(entry, []byte("corrupt checkpoint bytes"), 0600); err != nil { + t.Fatal(err) + } + if _, err := verifyLocalModel(root, m, io.Discard); err == nil { + t.Fatal("corrupt local model accepted") + } + os.Remove(entry) + if err := os.Symlink(filepath.Join(root, "target"), entry); err != nil { + t.Fatal(err) + } + if _, err := verifyLocalModel(root, m, io.Discard); err == nil { + t.Fatal("symlinked local model file accepted") + } +} + func TestAppleChipAllowlist(t *testing.T) { for chip, ok := range map[string]bool{"Apple M4 Pro": true, "Apple M5 Pro": true, "Apple M5 Max": true, "Apple M4 Max": true, "Apple M4": false, "Apple M1 Pro": false} { if (checkAppleChip(chip) == nil) != ok { @@ -363,6 +428,72 @@ func TestAppleChipAllowlist(t *testing.T) { } } +func TestNativeHIPRequiresLocalModelDirectory(t *testing.T) { + native := Manifest{Engine: "flint-native"} + if err := validateModelSource("serve", native, ""); err == nil { + t.Fatal("native serve accepted without --model-dir") + } + if err := validateModelSource("pull", native, ""); err == nil { + t.Fatal("native pull accepted remote checkpoint transport") + } + if err := validateModelSource("serve", native, "/models/flint"); err != nil { + t.Fatalf("native local model rejected: %v", err) + } + if err := validateModelSource("serve", Manifest{Engine: "mlx"}, ""); err != nil { + t.Fatalf("MLX download behavior changed: %v", err) + } +} + +func TestNativeHIPLaunchDoesNotAutostartWithoutModelDirectory(t *testing.T) { + if err := validateLaunchAutostart(Manifest{Engine: "flint-native"}); err == nil { + t.Fatal("native launch accepted downloader/background autostart path") + } + if err := validateLaunchAutostart(Manifest{Engine: "mlx"}); err != nil { + t.Fatalf("MLX launch autostart was rejected: %v", err) + } +} + +func TestNativeHIPPreflightOnLinuxAMD64(t *testing.T) { + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + t.Skip("native HIP target is Linux/amd64") + } + oldProbe := nativeHIPProbe + nativeHIPProbe = func() error { return nil } + defer func() { nativeHIPProbe = oldProbe }() + m := Manifest{Engine: "flint-native", OS: "linux", Arch: "amd64", Backend: "hip", MinRAM: 1} + if err := preflight(m); err != nil { + t.Fatalf("native HIP host rejected: %v", err) + } +} + +func TestNativeHIPHardwareRequiresGFX1151(t *testing.T) { + root := t.TempDir() + kfd := filepath.Join(root, "kfd") + if err := os.WriteFile(kfd, nil, 0600); err != nil { + t.Fatal(err) + } + nodes := filepath.Join(root, "nodes") + if err := os.MkdirAll(filepath.Join(nodes, "1"), 0700); err != nil { + t.Fatal(err) + } + properties := filepath.Join(nodes, "1", "properties") + write := func(target string) { + t.Helper() + content := "simd_count 80\nwave_front_size 32\ngfx_target_version " + target + "\n" + if err := os.WriteFile(properties, []byte(content), 0600); err != nil { + t.Fatal(err) + } + } + write("110501") + if err := nativeHIPHardware(kfd, filepath.Join(nodes, "*", "properties")); err != nil { + t.Fatalf("gfx1151 rejected: %v", err) + } + write("110500") + if err := nativeHIPHardware(kfd, filepath.Join(nodes, "*", "properties")); err == nil { + t.Fatal("non-gfx1151 device accepted") + } +} + func discardProgress() *transfer { return newTransfer(io.Discard, "Downloading", "Downloaded", 0, 0) } func TestStampRejectsRestoredMtime(t *testing.T) { diff --git a/cli/serve.go b/cli/serve.go index c4def68..492aa21 100644 --- a/cli/serve.go +++ b/cli/serve.go @@ -210,7 +210,7 @@ func probe(ctx context.Context, base, key, model string, phase ...func(string)) return nil } -func serve(parent context.Context, p Paths, m Manifest, override string, port int, out io.Writer, background ...bool) error { +func serve(parent context.Context, p Paths, m Manifest, override, modelDir string, port int, out io.Writer, background ...bool) error { l, e := lock(filepath.Join(p.State, "instance.lock")) if e != nil { if s, err := control(p, "status"); err == nil { @@ -262,10 +262,10 @@ func serve(parent context.Context, p Paths, m Manifest, override string, port in go func() { _ = srv.Serve(listener) }() ui := newScreen(out) if e = preflight(m); e != nil { - ui.failed("This Mac is not supported", "") + ui.failed("This system is not supported", "") return e } - ui.succeeded("This Mac is supported", "") + ui.succeeded("This system is supported", "") runtimeProgress := startActivity(out, "Preparing runtime", "Runtime ready") runtimeDir, e := ensureRuntime(p, m, override) if e != nil { @@ -273,7 +273,18 @@ func serve(parent context.Context, p Paths, m Manifest, override string, port in return e } runtimeProgress.succeed() - model, e := ensureModel(ctx, p, m, out) + var model string + if m.Engine == "flint-native" { + if modelDir == "" { + return fmt.Errorf("native Flint requires --model-dir DIR") + } + model, e = verifyLocalModel(modelDir, m, out) + } else { + if modelDir != "" { + return fmt.Errorf("--model-dir is supported only by the native Flint engine") + } + model, e = ensureModel(ctx, p, m, out) + } if e != nil { return e } diff --git a/cli/serve_test.go b/cli/serve_test.go index 050d204..72ff727 100644 --- a/cli/serve_test.go +++ b/cli/serve_test.go @@ -49,8 +49,14 @@ func (b *lockedBuffer) String() string { } func TestServeLifecycle(t *testing.T) { - if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { - t.Skip("serving requires Apple Silicon") + if (runtime.GOOS != "darwin" || runtime.GOARCH != "arm64") && + (runtime.GOOS != "linux" || runtime.GOARCH != "amd64") { + t.Skip("serving lifecycle target unavailable") + } + if runtime.GOOS == "linux" { + oldProbe := nativeHIPProbe + nativeHIPProbe = func() error { return nil } + defer func() { nativeHIPProbe = oldProbe }() } // Unix socket paths are limited to 104 bytes on macOS; t.TempDir is too long. root, err := os.MkdirTemp("/tmp", "rec-test-") @@ -75,16 +81,25 @@ func TestServeLifecycle(t *testing.T) { if err := os.Mkdir(runtimeDir, 0700); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(runtimeDir, runtimeServer), server, 0700); err != nil { + engine, backend, serverName := "mlx", "metal", runtimeServer + if runtime.GOOS == "linux" { + engine, backend, serverName = "flint-native", "hip", "flint-serve" + } + if err := os.WriteFile(filepath.Join(runtimeDir, serverName), server, 0700); err != nil { t.Fatal(err) } - m := Manifest{Engine: "mlx", Schema: 1, Model: "test", Revision: "test", Development: true, OS: "darwin", Arch: "arm64", Backend: "metal", + m := Manifest{Engine: engine, Schema: 1, Model: "test", Revision: "test", Development: true, OS: runtime.GOOS, Arch: runtime.GOARCH, Backend: backend, Context: 2048, MinRAM: 1, Entry: "config.json", Files: []File{{Name: "config.json", URL: weights.URL + "/config.json", Size: int64(len(config)), SHA256: digest(config)}}, - RuntimeSHA: digest([]byte("runtime")), RuntimeFiles: []File{{Name: runtimeServer, Size: int64(len(server)), SHA256: digest(server), Executable: true}}} - if _, err := ensureModelWithClient(context.Background(), p, m, io.Discard, weights.Client()); err != nil { + RuntimeSHA: digest([]byte("runtime")), RuntimeFiles: []File{{Name: serverName, Size: int64(len(server)), SHA256: digest(server), Executable: true}}} + modelPath, err := ensureModelWithClient(context.Background(), p, m, io.Discard, weights.Client()) + if err != nil { t.Fatal(err) } + modelDir := "" + if engine == "flint-native" { + modelDir = filepath.Dir(modelPath) + } weights.Close() listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -95,7 +110,7 @@ func TestServeLifecycle(t *testing.T) { var out lockedBuffer done := make(chan error, 1) - go func() { done <- serve(context.Background(), p, m, runtimeDir, port, &out) }() + go func() { done <- serve(context.Background(), p, m, runtimeDir, modelDir, port, &out) }() deadline := time.Now().Add(2 * time.Minute) var s Status for !s.Ready { @@ -110,17 +125,17 @@ func TestServeLifecycle(t *testing.T) { time.Sleep(100 * time.Millisecond) s, _ = control(p, "status") } - if !strings.Contains(out.String(), "test is ready.") || s.Endpoint != fmt.Sprintf("http://127.0.0.1:%d/v1", port) || s.Engine != "mlx" { + if !strings.Contains(out.String(), "test is ready.") || s.Endpoint != fmt.Sprintf("http://127.0.0.1:%d/v1", port) || s.Engine != engine { t.Fatalf("wrong readiness report %+v:\n%s", s, out.String()) } var again bytes.Buffer - if err := serve(context.Background(), p, m, runtimeDir, port, &again); err != nil || !strings.Contains(again.String(), "test is already running") { + if err := serve(context.Background(), p, m, runtimeDir, modelDir, port, &again); err != nil || !strings.Contains(again.String(), "test is already running") { t.Fatalf("identical run did not reuse the server: %v %s", err, again.String()) } changed := m changed.Context, changed.MaxContext = 4096, 4096 - if err := serve(context.Background(), p, changed, runtimeDir, port, io.Discard); err == nil { + if err := serve(context.Background(), p, changed, runtimeDir, modelDir, port, io.Discard); err == nil { t.Fatal("a different context reused the running server") } diff --git a/cli/testdata/fake-rec-mlx b/cli/testdata/fake-rec-mlx index 8a56828..b25acf4 100755 --- a/cli/testdata/fake-rec-mlx +++ b/cli/testdata/fake-rec-mlx @@ -6,14 +6,30 @@ import json from pathlib import Path parser = argparse.ArgumentParser() -parser.add_argument("command", choices=("serve",)) -parser.add_argument("model") -for flag in ("--model-dir", "--manifest", "--api-key-file"): - parser.add_argument(flag, type=Path, required=True) +native = len(__import__('sys').argv) > 1 and __import__('sys').argv[1].startswith('--') +if native: + for flag in ("--model", "--checkpoint-dir", "--api-key-file"): + parser.add_argument(flag, type=Path, required=True) + parser.add_argument("--context-capacity", type=int, required=True) + parser.add_argument("--device-budget", required=True) + parser.add_argument("--bind", required=True) + parser.add_argument("--queue-limit", required=True) + parser.add_argument("--served-model", required=True) + parser.add_argument("--enforce-tool-schemas", action="store_true") +else: + parser.add_argument("command", choices=("serve",)) + parser.add_argument("model") + for flag in ("--model-dir", "--manifest", "--api-key-file"): + parser.add_argument(flag, type=Path, required=True) + parser.add_argument("--context-limit", type=int, required=True) parser.add_argument("--port", type=int, required=True) -parser.add_argument("--context-limit", type=int, required=True) args = parser.parse_args() -assert (args.model_dir / args.manifest.name).is_file() +if native: + assert args.checkpoint_dir == args.model + args.model = args.served_model + assert args.model +else: + assert (args.model_dir / args.manifest.name).is_file() key = args.api_key_file.read_text().strip() diff --git a/packaging/build-flint-runtime.py b/packaging/build-flint-runtime.py new file mode 100644 index 0000000..175aadb --- /dev/null +++ b/packaging/build-flint-runtime.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Stage a relocatable native Flint executable for REC packaging.""" + +import argparse +import hashlib +import json +import os +import re +import stat +import subprocess +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--flint-serve", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + + source = args.flint_serve + info = source.lstat() + if source.is_symlink() or not stat.S_ISREG(info.st_mode) or not info.st_mode & 0o111: + parser.error("--flint-serve must be an executable regular file, not a symlink") + fd = os.open(source, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW) + opened = os.fstat(fd) + identity = lambda value: ( + value.st_dev, value.st_ino, value.st_mode, value.st_size, + value.st_mtime_ns, value.st_ctime_ns, + ) + if (info.st_dev, info.st_ino) != (opened.st_dev, opened.st_ino): + os.close(fd) + parser.error("--flint-serve changed while being opened") + executable = f"/proc/self/fd/{fd}" + probe = subprocess.run( + [executable, "--help"], capture_output=True, text=True, timeout=30, + pass_fds=(fd,), + ) + if probe.returncode != 0 or "flint-serve" not in probe.stdout or "--checkpoint-dir" not in probe.stdout: + parser.error("executable does not satisfy the native Flint server contract") + contract_probe = subprocess.run( + [executable, "--runtime-contract"], capture_output=True, text=True, timeout=30, + pass_fds=(fd,), + ) + try: + contract = json.loads(contract_probe.stdout) + except (json.JSONDecodeError, TypeError): + contract = None + fixed = { + "schema": 1, + "engine": "flint-native", + "os": "linux", + "arch": "amd64", + "backend": "hip", + "revision": "3b93f9053b530488405ae9fca51d4900d6ce21f0", + "max_context": 262144, + "arena_base_bytes": 25074426720, + "kv_bytes_per_token": 28672, + "sampler_bytes": 1951872, + } + source_keys = {"source_commit", "source_tree", "source_clean"} + if ( + contract_probe.returncode != 0 + or not isinstance(contract, dict) + or set(contract) != set(fixed) | source_keys + or any(contract.get(key) != value for key, value in fixed.items()) + or contract.get("source_clean") is not True + or not all(re.fullmatch(r"[0-9a-f]{40}", str(contract.get(key, ""))) for key in ("source_commit", "source_tree")) + ): + os.close(fd) + parser.error("executable runtime contract is missing, dirty, or incompatible") + if identity(os.fstat(fd)) != identity(opened): + os.close(fd) + parser.error("--flint-serve changed while being probed") + + requested_output = args.output + if requested_output.exists() or requested_output.is_symlink(): + parser.error("--output must not already exist") + output = requested_output.resolve() + output.mkdir(parents=True, mode=0o700) + destination = output / "flint-serve" + digest = hashlib.sha256() + os.lseek(fd, 0, os.SEEK_SET) + with destination.open("xb") as staged: + while chunk := os.read(fd, 1024 * 1024): + staged.write(chunk) + digest.update(chunk) + if identity(os.fstat(fd)) != identity(opened): + os.close(fd) + parser.error("--flint-serve changed while being staged") + os.close(fd) + destination.chmod(0o755) + binary_sha256 = digest.hexdigest() + (output / "build.json").write_text( + json.dumps( + {**{key: fixed[key] for key in ("engine", "os", "arch", "backend")}, + "binary_sha256": binary_sha256, "runtime_contract": contract}, + indent=2, + ) + + "\n" + ) + print(output) + + +if __name__ == "__main__": + main() diff --git a/packaging/flint-int4-hip.json b/packaging/flint-int4-hip.json new file mode 100644 index 0000000..e780e1d --- /dev/null +++ b/packaging/flint-int4-hip.json @@ -0,0 +1,91 @@ +{ + "schema": 1, + "model": "flint", + "revision": "3b93f9053b530488405ae9fca51d4900d6ce21f0", + "development": false, + "engine": "flint-native", + "os": "linux", + "arch": "amd64", + "backend": "hip", + "context": 262144, + "max_context": 262144, + "runtime_binary_sha256": "8fe875a4ccb9715c2d293dd3e0f6e389d0878a416de6679fa3daf7241c4c9b18", + "runtime_source_commit": "f09b8f04df3e1a44a2d03c3c7f5119dcc6793415", + "runtime_source_tree": "d7e75527a7c11cc71c14f68b2ebd72277e4fe592", + "min_ram_bytes": 8589934592, + "entry": "flint-int4.json", + "files": [ + { + "name": "chat_template.jinja", + "url": "https://huggingface.co/RecursiveSI/flint-int4/resolve/3b93f9053b530488405ae9fca51d4900d6ce21f0/chat_template.jinja", + "size": 2328, + "sha256": "7940f4ffa3275c36caa4a582f845aaad79da11018b3961e8fbcad6ae29733466" + }, + { + "name": "config.json", + "url": "https://huggingface.co/RecursiveSI/flint-int4/resolve/3b93f9053b530488405ae9fca51d4900d6ce21f0/config.json", + "size": 3082, + "sha256": "80f577a984db1bd40dfff88457b611d4c13e807704a945616be27f28809eed21" + }, + { + "name": "generation_config.json", + "url": "https://huggingface.co/RecursiveSI/flint-int4/resolve/3b93f9053b530488405ae9fca51d4900d6ce21f0/generation_config.json", + "size": 202, + "sha256": "a499c71895e008abd572558a5ad5904feaed92cf2a3b6e2cb0e9cabf03b96ed3" + }, + { + "name": "model-00000.safetensors", + "url": "https://huggingface.co/RecursiveSI/flint-int4/resolve/3b93f9053b530488405ae9fca51d4900d6ce21f0/model-00000.safetensors", + "size": 4745746152, + "sha256": "1a362742a0136c95ed751e2c56d83e63d4ed9ac674984b23b368f105d16cfe7a" + }, + { + "name": "model-00001.safetensors", + "url": "https://huggingface.co/RecursiveSI/flint-int4/resolve/3b93f9053b530488405ae9fca51d4900d6ce21f0/model-00001.safetensors", + "size": 4682022280, + "sha256": "12979140fa019042ff5cdec4c0237cc87db13b56428ce7fe093dd9d33893ea32" + }, + { + "name": "model-00002.safetensors", + "url": "https://huggingface.co/RecursiveSI/flint-int4/resolve/3b93f9053b530488405ae9fca51d4900d6ce21f0/model-00002.safetensors", + "size": 4682022280, + "sha256": "01805c6b882fdec008b0184ec68e7516102205b521e63ca5126f9d52b9aed87b" + }, + { + "name": "model-00003.safetensors", + "url": "https://huggingface.co/RecursiveSI/flint-int4/resolve/3b93f9053b530488405ae9fca51d4900d6ce21f0/model-00003.safetensors", + "size": 4682022280, + "sha256": "9380ed032d90e0ff566d49672faf292f354b3b0e7320820c150eeed8c75e9d42" + }, + { + "name": "model-00004.safetensors", + "url": "https://huggingface.co/RecursiveSI/flint-int4/resolve/3b93f9053b530488405ae9fca51d4900d6ce21f0/model-00004.safetensors", + "size": 3777099608, + "sha256": "c1ba12d2c2a80feb5e554554a01cfde2b71c2638e27fc0ee597b4121399eff99" + }, + { + "name": "special_tokens_map.json", + "url": "https://huggingface.co/RecursiveSI/flint-int4/resolve/3b93f9053b530488405ae9fca51d4900d6ce21f0/special_tokens_map.json", + "size": 20192, + "sha256": "be37b302338dda189bc70bfbe902d8faebeb5986d60c1342713439eefc4ca182" + }, + { + "name": "tokenizer.json", + "url": "https://huggingface.co/RecursiveSI/flint-int4/resolve/3b93f9053b530488405ae9fca51d4900d6ce21f0/tokenizer.json", + "size": 10879128, + "sha256": "a263cce0b7e2b03f7ee07ce809783ef4b47f160c3f268a4ea1524f638da45b68" + }, + { + "name": "tokenizer_config.json", + "url": "https://huggingface.co/RecursiveSI/flint-int4/resolve/3b93f9053b530488405ae9fca51d4900d6ce21f0/tokenizer_config.json", + "size": 30439, + "sha256": "159ac88217e90ffd0a4a294726048bb302829e82e1958dc85bb203aa79138fbf" + }, + { + "name": "flint-int4.json", + "url": "https://huggingface.co/RecursiveSI/flint-int4/resolve/3b93f9053b530488405ae9fca51d4900d6ce21f0/flint-int4.json", + "size": 1206, + "sha256": "319822f10e32a5916b57e23c7f05f9e92b24d02b3a136d02a3846b5edab78109" + } + ] +} diff --git a/packaging/package.py b/packaging/package.py index 78adce8..6f0e9bb 100644 --- a/packaging/package.py +++ b/packaging/package.py @@ -14,17 +14,75 @@ import tempfile from pathlib import Path +TRUSTED_NATIVE_RUNTIME = { + "binary": "8fe875a4ccb9715c2d293dd3e0f6e389d0878a416de6679fa3daf7241c4c9b18", + "commit": "f09b8f04df3e1a44a2d03c3c7f5119dcc6793415", + "tree": "d7e75527a7c11cc71c14f68b2ebd72277e4fe592", +} + def sha(data): return hashlib.sha256(data).hexdigest() +def validate_native_runtime(runtime_dir, manifest, metadata): + if manifest["engine"] != "flint-native": + return + server = runtime_dir / "flint-serve" + if server.is_symlink() or not server.is_file(): + raise ValueError("native runtime contract executable is missing or unsafe") + expected_keys = {"engine", "os", "arch", "backend", "binary_sha256", "runtime_contract"} + if set(metadata) != expected_keys: + raise ValueError("native runtime contract metadata is incomplete") + if metadata["binary_sha256"] != sha(server.read_bytes()): + raise ValueError("native runtime contract binary digest mismatch") + trusted = { + "binary": manifest.get("runtime_binary_sha256"), + "commit": manifest.get("runtime_source_commit"), + "tree": manifest.get("runtime_source_tree"), + } + if (not re.fullmatch(r"[0-9a-f]{64}", str(trusted["binary"] or "")) + or not all(re.fullmatch(r"[0-9a-f]{40}", str(trusted[key] or "")) + for key in ("commit", "tree"))): + raise ValueError("trusted runtime pins are missing") + if trusted != TRUSTED_NATIVE_RUNTIME: + raise ValueError("trusted runtime manifest pins do not match the reviewed package policy") + if metadata["binary_sha256"] != trusted["binary"]: + raise ValueError("trusted runtime binary digest mismatch") + contract = metadata["runtime_contract"] + fixed = { + "schema": 1, "engine": "flint-native", "os": "linux", "arch": "amd64", + "backend": "hip", "revision": manifest["revision"], + "max_context": manifest["max_context"], "arena_base_bytes": 25074426720, + "kv_bytes_per_token": 28672, "sampler_bytes": 1951872, + } + source_keys = {"source_commit", "source_tree", "source_clean"} + if (not isinstance(contract, dict) or set(contract) != set(fixed) | source_keys + or any(contract.get(key) != value for key, value in fixed.items()) + or contract.get("source_clean") is not True + or not all(re.fullmatch(r"[0-9a-f]{40}", str(contract.get(key, ""))) + for key in ("source_commit", "source_tree"))): + raise ValueError("native runtime contract is incompatible") + if (contract["source_commit"] != trusted["commit"] + or contract["source_tree"] != trusted["tree"]): + raise ValueError("trusted runtime source identity mismatch") + probe = subprocess.run([str(server.resolve()), "--runtime-contract"], + capture_output=True, text=True, timeout=30) + try: + live = json.loads(probe.stdout) + except json.JSONDecodeError: + live = None + if probe.returncode != 0 or live != contract: + raise ValueError("native runtime contract probe disagrees with metadata") + + def payload(runtime_dir, manifest): m = json.loads(manifest.read_text()) metadata = json.loads((runtime_dir / "build.json").read_text()) for key in ("engine", "os", "arch", "backend"): if metadata[key] != m[key]: raise ValueError(f"runtime and model disagree on {key}") + validate_native_runtime(runtime_dir, m, metadata) files = [] buf = io.BytesIO() # Fixed gzip timestamp makes equivalent payloads content-identical. @@ -57,7 +115,7 @@ def main(): "--runtime-dir", type=Path, required=True, - help="runtime directory from packaging/build-mlx-runtime.py", + help="prebuilt runtime directory matching the manifest target", ) p.add_argument( "--manifest", diff --git a/tests/package_test.py b/tests/package_test.py index 6d9f18b..a8a5205 100644 --- a/tests/package_test.py +++ b/tests/package_test.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +import hashlib import json import platform import re @@ -56,4 +57,109 @@ subprocess.check_output([str(output), "--version"], text=True).strip() == "rec 0.1.0-test" ) + fake_flint = work / "source-flint-serve" + fake_flint.write_text( + "#!/bin/sh\n" + "if [ \"$1\" = --help ]; then echo 'flint-serve --checkpoint-dir'; exit 0; fi\n" + "exit 1\n" + ) + fake_flint.chmod(0o755) + rejected = subprocess.run( + [sys.executable, str(root / "packaging/build-flint-runtime.py"), + "--flint-serve", str(fake_flint), "--output", str(work / "bad-contract-runtime")], + capture_output=True, text=True, + ) + assert rejected.returncode != 0 and "contract" in rejected.stderr, rejected.stderr + fake_flint.write_text( + "#!/bin/sh\n" + "if [ \"$1\" = --help ]; then echo 'flint-serve --checkpoint-dir'; exit 0; fi\n" + "if [ \"$1\" = --runtime-contract ]; then echo '{\"schema\":1,\"engine\":\"flint-native\",\"os\":\"linux\",\"arch\":\"amd64\",\"backend\":\"hip\",\"revision\":\"3b93f9053b530488405ae9fca51d4900d6ce21f0\",\"max_context\":262144,\"arena_base_bytes\":25074426720,\"kv_bytes_per_token\":28672,\"sampler_bytes\":1951872,\"source_commit\":\"0123456789abcdef0123456789abcdef01234567\",\"source_tree\":\"89abcdef0123456789abcdef0123456789abcdef\",\"source_clean\":true}'; exit 0; fi\n" + "exit 1\n" + ) + hip_runtime = work / "hip-runtime" + subprocess.run( + [sys.executable, str(root / "packaging/build-flint-runtime.py"), + "--flint-serve", str(fake_flint), "--output", str(hip_runtime)], + check=True, + ) + identity = json.loads((hip_runtime / "build.json").read_text()) + target = {key: identity[key] for key in ("engine", "os", "arch", "backend")} + assert target == {"engine": "flint-native", "os": "linux", "arch": "amd64", "backend": "hip"} + assert identity["binary_sha256"] == hashlib.sha256((hip_runtime / "flint-serve").read_bytes()).hexdigest() + assert identity["runtime_contract"]["source_clean"] is True + assert (hip_runtime / "flint-serve").stat().st_mode & 0o111 + sentinel = hip_runtime / "keep-me" + sentinel.write_text("do not delete\n") + rejected = subprocess.run( + [sys.executable, str(root / "packaging/build-flint-runtime.py"), + "--flint-serve", str(fake_flint), "--output", str(hip_runtime)], + capture_output=True, text=True, + ) + assert rejected.returncode != 0 and sentinel.read_text() == "do not delete\n", rejected.stderr + bad = work / "flint-link" + bad.symlink_to(fake_flint) + rejected = subprocess.run( + [sys.executable, str(root / "packaging/build-flint-runtime.py"), + "--flint-serve", str(bad), "--output", str(work / "bad-runtime")], + capture_output=True, text=True, + ) + assert rejected.returncode != 0 and "symlink" in rejected.stderr, rejected.stderr + + # A successful probe must be bound to the exact inode subsequently staged. + mutating = work / "mutating-flint-serve" + contract_json = json.dumps(identity["runtime_contract"], separators=(",", ":")) + mutating.write_text( + "#!/bin/sh\n" + "if [ \"$1\" = --help ]; then echo 'flint-serve --checkpoint-dir'; exit 0; fi\n" + f"if [ \"$1\" = --runtime-contract ]; then echo '{contract_json}'; " + "printf '#!/bin/sh\\necho MUTATED-NO-CONTRACT\\n' > \"$0.new\"; " + "chmod 755 \"$0.new\"; mv \"$0.new\" \"$0\"; exit 0; fi\n" + "exit 1\n" + ) + mutating.chmod(0o755) + bound_runtime = work / "identity-bound-runtime" + subprocess.run( + [sys.executable, str(root / "packaging/build-flint-runtime.py"), + "--flint-serve", str(mutating), "--output", str(bound_runtime)], + check=True, + ) + staged = bound_runtime / "flint-serve" + staged_probe = subprocess.run( + [str(staged), "--runtime-contract"], capture_output=True, text=True + ) + assert staged_probe.returncode == 0 + assert json.loads(staged_probe.stdout) == identity["runtime_contract"], staged_probe.stdout + + # Native packages must not trust a four-field build.json around an arbitrary executable. + hip_manifest = root / "packaging/flint-int4-hip.json" + arbitrary = work / "arbitrary-runtime" + arbitrary.mkdir() + arbitrary_server = arbitrary / "flint-serve" + arbitrary_contract = dict(identity["runtime_contract"]) + arbitrary_server.write_text( + "#!/bin/sh\n" + f"if [ \"$1\" = --runtime-contract ]; then echo '{json.dumps(arbitrary_contract, separators=(',', ':'))}'; exit 0; fi\n" + "exit 0\n" + ) + arbitrary_server.chmod(0o755) + arbitrary_m = json.loads(hip_manifest.read_text()) + (arbitrary / "build.json").write_text(json.dumps({ + **{k: arbitrary_m[k] for k in ("engine", "os", "arch", "backend")}, + "binary_sha256": hashlib.sha256(arbitrary_server.read_bytes()).hexdigest(), + "runtime_contract": arbitrary_contract, + })) + forged_manifest = work / "forged-native-manifest.json" + arbitrary_m.update({ + "runtime_binary_sha256": hashlib.sha256(arbitrary_server.read_bytes()).hexdigest(), + "runtime_source_commit": arbitrary_contract["source_commit"], + "runtime_source_tree": arbitrary_contract["source_tree"], + }) + forged_manifest.write_text(json.dumps(arbitrary_m)) + rejected = subprocess.run( + [sys.executable, str(root / "packaging/package.py"), + "--runtime-dir", str(arbitrary), "--manifest", str(forged_manifest), + "--version", "0.1.0-test", "--output", str(work / "bad-native-rec")], + capture_output=True, text=True, + ) + assert rejected.returncode != 0 and "trusted runtime" in rejected.stderr, rejected.stderr print("Packaging, archive validation and runtime/manifest mismatch rejection passed")