From f647df568d087cc7d21b19e74ce19aa17948775c Mon Sep 17 00:00:00 2001 From: mitchell Date: Wed, 8 Jul 2026 12:49:51 -0400 Subject: [PATCH 01/15] Added run script for testing encrypted private ingredients. --- .gitignore | 1 + activestate.yaml | 28 ++++++++++ scripts/orgkeyserver.py | 116 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 scripts/orgkeyserver.py diff --git a/.gitignore b/.gitignore index eff1608360..5e61f30094 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ cmd/state/versioninfo.json cmd/state/resource.syso cmd/state-svc/versioninfo.json cmd/state-svc/resource.syso +test/ssl/ diff --git a/activestate.yaml b/activestate.yaml index 19bff2be58..d1cfd84a0b 100644 --- a/activestate.yaml +++ b/activestate.yaml @@ -459,6 +459,34 @@ scripts: language: bash standalone: true value: go run $project.path()/scripts/to-buildexpression/main.go $@ + - name: serve-org-keys + language: bash + description: Runs a server that serves org keys for testing encrypted private ingredients + standalone: true + value: | + org="$1" + if [ -z "$org" ]; then + echo "Usage: state run serve-org-keys []" + echo "Error: missing organization name" + exit 1 + fi + key="$2" + + echo "Generating HTTPS certificate." + if [ ! -d test/ssl ]; then mkdir test/ssl; fi + rm -f test/ssl/{key,cert}.pem + state exec openssl -- req -x509 -newkey rsa:2048 -nodes -days 365 \ + -keyout test/ssl/key.pem -out test/ssl/cert.pem \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" 2> /dev/null + + echo "Starting org key server." + if [ -z "$key" ]; then + echo "Using random encryption key (see below)" + state exec python3 -- scripts/orgkeyserver.py --tls-cert test/ssl/cert.pem --tls-key test/ssl/key.pem --org $org + else + state exec python3 -- scripts/orgkeyserver.py --tls-cert test/ssl/cert.pem --tls-key test/ssl/key.pem --org "$org" --key "$key" + fi events: - name: activate diff --git a/scripts/orgkeyserver.py b/scripts/orgkeyserver.py new file mode 100644 index 0000000000..a25cdc6ab1 --- /dev/null +++ b/scripts/orgkeyserver.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Minimal org-key service for locally testing private ingredients. + +Serves the organization encryption key over HTTPS at GET /v1/org-key in the +contract the State Tool expects. For local testing only; not a shipped artifact. + +Generate a self-signed certificate (the SAN must cover the host you connect to): + + openssl req -x509 -newkey rsa:2048 -nodes -days 365 \ + -keyout key.pem -out cert.pem \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" + +Run: + + python3 scripts/orgkeyserver.py --tls-cert cert.pem --tls-key key.pem + +Point the State Tool at it (the base URL only; the tool appends /v1/org-key): + + state config set privateingredient.key_service_url https://127.0.0.1:8443 + state config set privateingredient.key_service_ca /path/to/cert.pem + # Optional bearer auth (start the server with --token ): + state config set privateingredient.bearer_token_env ORGKEY_TOKEN + export ORGKEY_TOKEN= + +`state publish --build` and the later install must share the same key, so reuse +the printed --key value across runs. +""" + +import argparse +import base64 +import hashlib +import json +import secrets +import ssl +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer + +KEY_SIZE = 32 # AES-256 +ENDPOINT = "/v1/org-key" + + +def build_contract(org, key_id, raw_key): + return { + "schema": "activestate.pim.orgkey/v1", + "org": org, + "key_id": key_id, + "algorithm": "AES-256-GCM", + "encoding": "base64", + "key": "b64:" + base64.standard_b64encode(raw_key).decode("ascii"), + "fingerprint": "sha256:" + hashlib.sha256(raw_key).hexdigest(), + } + + +def make_handler(contract, token): + body = json.dumps(contract).encode("utf-8") + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path != ENDPOINT: + self.send_error(404) + return + if token and self.headers.get("Authorization") != "Bearer " + token: + self.send_error(401) + return + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + return Handler + + +def parse_key(encoded): + raw = base64.standard_b64decode(encoded) + if len(raw) != KEY_SIZE: + raise SystemExit(f"--key must decode to {KEY_SIZE} bytes, got {len(raw)}") + return raw + + +def main(): + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument("--tls-cert", required=True, help="server TLS certificate (PEM)") + p.add_argument("--tls-key", required=True, help="server TLS private key (PEM)") + p.add_argument("--org", default="ActiveState-CLI-Testing", + help="organization the key belongs to; must match the project owner") + p.add_argument("--key", help="base64-encoded 32-byte AES key; generated and printed if omitted") + p.add_argument("--key-id", default="orgkey-test", help="opaque key identifier") + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, default=8443) + p.add_argument("--token", help="if set, require this value as a bearer token") + args = p.parse_args() + + if args.key: + raw_key = parse_key(args.key) + else: + raw_key = secrets.token_bytes(KEY_SIZE) + print("--key", base64.standard_b64encode(raw_key).decode("ascii"), file=sys.stderr) + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 + ctx.load_cert_chain(certfile=args.tls_cert, keyfile=args.tls_key) + + handler = make_handler(build_contract(args.org, args.key_id, raw_key), args.token) + httpd = HTTPServer((args.host, args.port), handler) + httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True) + print(f"serving {ENDPOINT} for org {args.org!r} on https://{args.host}:{args.port}", file=sys.stderr) + try: + httpd.serve_forever() + except KeyboardInterrupt: + httpd.shutdown() + + +if __name__ == "__main__": + main() From c09068558f6643373dea01aea88442c3942c85a0 Mon Sep 17 00:00:00 2001 From: mitchell Date: Thu, 9 Jul 2026 16:49:44 -0400 Subject: [PATCH 02/15] Updated version.txt. --- version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.txt b/version.txt index c325dfb59d..116e4d868e 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.48.1-RC2 +0.48.1-RC3 From fd78f3e300d3794176f123bb82dd2b3ae427cff0 Mon Sep 17 00:00:00 2001 From: mitchell Date: Fri, 10 Jul 2026 09:28:39 -0400 Subject: [PATCH 03/15] Run script for testing encrypted private ingredients should not be standalone. --- activestate.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/activestate.yaml b/activestate.yaml index d1cfd84a0b..f5e7354177 100644 --- a/activestate.yaml +++ b/activestate.yaml @@ -462,7 +462,6 @@ scripts: - name: serve-org-keys language: bash description: Runs a server that serves org keys for testing encrypted private ingredients - standalone: true value: | org="$1" if [ -z "$org" ]; then @@ -475,7 +474,7 @@ scripts: echo "Generating HTTPS certificate." if [ ! -d test/ssl ]; then mkdir test/ssl; fi rm -f test/ssl/{key,cert}.pem - state exec openssl -- req -x509 -newkey rsa:2048 -nodes -days 365 \ + openssl req -x509 -newkey rsa:2048 -nodes -days 365 \ -keyout test/ssl/key.pem -out test/ssl/cert.pem \ -subj "/CN=localhost" \ -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" 2> /dev/null @@ -483,9 +482,9 @@ scripts: echo "Starting org key server." if [ -z "$key" ]; then echo "Using random encryption key (see below)" - state exec python3 -- scripts/orgkeyserver.py --tls-cert test/ssl/cert.pem --tls-key test/ssl/key.pem --org $org + python3 -- scripts/orgkeyserver.py --tls-cert test/ssl/cert.pem --tls-key test/ssl/key.pem --org $org else - state exec python3 -- scripts/orgkeyserver.py --tls-cert test/ssl/cert.pem --tls-key test/ssl/key.pem --org "$org" --key "$key" + python3 -- scripts/orgkeyserver.py --tls-cert test/ssl/cert.pem --tls-key test/ssl/key.pem --org "$org" --key "$key" fi events: From ae0a1e550daa0e7ee48335336c78690f44f0c422 Mon Sep 17 00:00:00 2001 From: mitchell Date: Fri, 10 Jul 2026 09:45:16 -0400 Subject: [PATCH 04/15] No need to remove existing SSL certs. --- activestate.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/activestate.yaml b/activestate.yaml index f5e7354177..67156608f0 100644 --- a/activestate.yaml +++ b/activestate.yaml @@ -473,7 +473,6 @@ scripts: echo "Generating HTTPS certificate." if [ ! -d test/ssl ]; then mkdir test/ssl; fi - rm -f test/ssl/{key,cert}.pem openssl req -x509 -newkey rsa:2048 -nodes -days 365 \ -keyout test/ssl/key.pem -out test/ssl/cert.pem \ -subj "/CN=localhost" \ From ca3d76770b647189dd903e17d6179fc0a51fdc84 Mon Sep 17 00:00:00 2001 From: mitchell Date: Fri, 10 Jul 2026 09:55:08 -0400 Subject: [PATCH 05/15] Update to Go 1.26.5 to remediate a CVE. --- .github/workflows/build.yml | 2 +- go.mod | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a048d63079..367bef1a5b 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,7 +36,7 @@ jobs: strategy: matrix: go-version: - - 1.26.4 + - 1.26.5 sys: - { os: ubuntu-latest } - { os: macos-15-intel, shell: zsh } diff --git a/go.mod b/go.mod index a8f48a6f6c..423130e401 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/ActiveState/cli -go 1.26.4 +go 1.26.5 require ( github.com/99designs/gqlgen v0.17.54 From 7354c4b38bee69c6dc0a856baca62566329bd337 Mon Sep 17 00:00:00 2001 From: mitchell Date: Mon, 13 Jul 2026 15:14:04 -0400 Subject: [PATCH 06/15] ENG-1461: State Tool graceful degradation on build-log WebSocket denial Detect a refused build-log-streamer stream as a distinct StreamDeniedError rather than an opaque build failure. Both deny shapes from the ENG-1457 gate-policy spike are handled: a hard 401/403 on the Upgrade (a Connect dial error) and a server soft-close (Upgrade accepted, then closed with no frames). Because the stream is the only source of artifact download URIs for an in-progress build, a denial there can't complete the install, so the runtime now reports a clear authentication/authorization message instead of a raw error. Genuine build failures and network errors are still surfaced, and an already-built checkout never opens the stream, so it is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/platform/api/buildlogstream/streamer.go | 16 +++++- .../api/buildlogstream/streamer_test.go | 50 +++++++++++++++++++ pkg/runtime/internal/buildlog/buildlog.go | 17 +++++++ .../internal/buildlog/buildlog_denial_test.go | 49 ++++++++++++++++++ pkg/runtime/setup.go | 9 ++++ 5 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 pkg/runtime/internal/buildlog/buildlog_denial_test.go diff --git a/pkg/platform/api/buildlogstream/streamer.go b/pkg/platform/api/buildlogstream/streamer.go index 619a13508c..cc5288a6db 100644 --- a/pkg/platform/api/buildlogstream/streamer.go +++ b/pkg/platform/api/buildlogstream/streamer.go @@ -1,6 +1,7 @@ package buildlogstream import ( + "errors" "net/http" "github.com/gorilla/websocket" @@ -18,6 +19,16 @@ import ( // keeping the token out of proxy/browser response logs. const wsSubprotocol = "build-log-streamer.activestate.com.v1" +// StreamDeniedError indicates the server refused the build-log stream. +type StreamDeniedError struct { + *errs.WrapperError +} + +func IsStreamDenied(err error) bool { + var e *StreamDeniedError + return errors.As(err, &e) +} + // Connect opens the build-log-streamer WebSocket. When jwt is non-empty it is // offered via Sec-WebSocket-Protocol as `bearer.` (alongside // wsSubprotocol, which the server echoes back) so the server can authorize the @@ -40,8 +51,11 @@ func Connect(ctx context.Context, jwt string) (*websocket.Conn, error) { } logging.Debug("Creating websocket for %s (origin: %s)", url.String(), header.Get("Origin")) - conn, _, err := dialer.DialContext(ctx, url.String(), header) + conn, resp, err := dialer.DialContext(ctx, url.String(), header) if err != nil { + if resp != nil && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) { + return nil, &StreamDeniedError{errs.Wrap(err, "build-log-streamer WebSocket Upgrade denied with status %d", resp.StatusCode)} + } return nil, errs.Wrap(err, "Could not create websocket dialer") } return conn, nil diff --git a/pkg/platform/api/buildlogstream/streamer_test.go b/pkg/platform/api/buildlogstream/streamer_test.go index 66e72545fa..599c835156 100644 --- a/pkg/platform/api/buildlogstream/streamer_test.go +++ b/pkg/platform/api/buildlogstream/streamer_test.go @@ -2,6 +2,7 @@ package buildlogstream import ( "context" + "errors" "net/http" "net/http/httptest" "strings" @@ -9,11 +10,20 @@ import ( "time" "github.com/ActiveState/cli/internal/constants" + "github.com/ActiveState/cli/internal/errs" "github.com/gorilla/websocket" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestIsStreamDenied(t *testing.T) { + denied := &StreamDeniedError{errs.New("denied")} + assert.True(t, IsStreamDenied(denied), "a StreamDeniedError must be recognized") + assert.True(t, IsStreamDenied(errs.Wrap(denied, "wrapped")), "denial must be recognized through wrapping") + assert.False(t, IsStreamDenied(errs.New("some other failure")), "an unrelated error must not be a denial") + assert.False(t, IsStreamDenied(nil), "nil must not be a denial") +} + // upgradeRequest captures the headers the build-log-streamer server saw on the // WS Upgrade. The mock handler writes the fields from the server goroutine and // closes recorded; callers must await() before reading the fields so there's a @@ -93,6 +103,46 @@ func TestConnect_ForwardsJWTViaSubprotocol(t *testing.T) { "client must send the versioned State Tool User-Agent so the server can monitor versions") } +// startDenyingBLS stands up a server that refuses the WS Upgrade with the given +// HTTP status, and redirects Connect's resolved service URL at it. +func startDenyingBLS(t *testing.T, status int) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + })) + t.Cleanup(srv.Close) + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + t.Setenv(constants.APIServiceOverrideEnvVarName+"BUILDLOG_STREAMER", wsURL) +} + +func TestConnect_UpgradeDeniedReturnsTypedError(t *testing.T) { + for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} { + status := status + t.Run(http.StatusText(status), func(t *testing.T) { + startDenyingBLS(t, status) + + _, err := Connect(context.Background(), "header.payload.signature") + require.Error(t, err) + var denied *StreamDeniedError + assert.Truef(t, errors.As(err, &denied), + "a %d Upgrade response must be classified as denial, got: %v", status, err) + }) + } +} + +func TestConnect_NonAuthDialErrorNotDenied(t *testing.T) { + // A handshake failure that isn't an auth rejection is a genuine error and + // must not be mistaken for a denial (the run should still surface it). + startDenyingBLS(t, http.StatusInternalServerError) + + _, err := Connect(context.Background(), "") + require.Error(t, err) + var denied *StreamDeniedError + assert.False(t, errors.As(err, &denied), + "a non-auth handshake failure must not be classified as denial") +} + func TestConnect_AnonymousOffersNoBearer(t *testing.T) { got := startMockBLS(t) diff --git a/pkg/runtime/internal/buildlog/buildlog.go b/pkg/runtime/internal/buildlog/buildlog.go index 94a7c29501..f28d92ca67 100644 --- a/pkg/runtime/internal/buildlog/buildlog.go +++ b/pkg/runtime/internal/buildlog/buildlog.go @@ -2,6 +2,7 @@ package buildlog import ( "context" + "errors" "fmt" "os" "strings" @@ -102,6 +103,10 @@ func (b *BuildLog) OnArtifactReady(id strfmt.UUID, cb func()) { func (b *BuildLog) Wait(ctx context.Context) error { conn, err := buildlogstream.Connect(ctx, b.authToken) if err != nil { + var denied *buildlogstream.StreamDeniedError + if errors.As(err, &denied) { + return denied + } return errs.Wrap(err, "Could not connect to build-log streamer build updates") } @@ -124,6 +129,10 @@ func (b *BuildLog) Wait(ctx context.Context) error { if err == nil { continue } + var denied *buildlogstream.StreamDeniedError + if errors.As(err, &denied) { + return denied // this is a singular error that arrives alone + } if rerr == nil { rerr = errs.New("failed build") } @@ -223,16 +232,24 @@ func (b *BuildLog) waitForBuildLog(ctx context.Context, conn *websocket.Conn, er } } + receivedFrame := false var artifactErr error for { var msg Message err := conn.ReadJSON(&msg) if err != nil { + // At this time, the server can either deny the websocket connect, or accept it and close it + // without sending any frames. We handle the latter here. + if !receivedFrame && websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + errCh <- &buildlogstream.StreamDeniedError{errs.Wrap(err, "build-log stream soft-closed with no frames")} + return + } // This should bubble up and logging it is just an extra measure to help with debugging logging.Debug("Encountered error: %s", errs.JoinMessage(err)) errCh <- err return } + receivedFrame = true if verboseLogging { logging.Debug("Received response: %s", msg.MessageTypeValue()) } diff --git a/pkg/runtime/internal/buildlog/buildlog_denial_test.go b/pkg/runtime/internal/buildlog/buildlog_denial_test.go new file mode 100644 index 0000000000..ec0d2e6c5e --- /dev/null +++ b/pkg/runtime/internal/buildlog/buildlog_denial_test.go @@ -0,0 +1,49 @@ +package buildlog + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ActiveState/cli/internal/constants" + "github.com/ActiveState/cli/pkg/buildplan" + "github.com/ActiveState/cli/pkg/platform/api/buildlogstream" + "github.com/go-openapi/strfmt" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestWait_SoftCloseIsDenial drives Wait against a server that accepts the +// Upgrade then soft-closes with no frames (one of the two deny shapes), and +// asserts the run degrades to a denial instead of surfacing a build failure. +func TestWait_SoftCloseIsDenial(t *testing.T) { + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("mock build-log-streamer failed to upgrade: %v", err) + return + } + // Drain the client's recipe request, then close normally with no build + // frames -- the server-side soft-close denial shape. + _, _, _ = conn.ReadMessage() + _ = conn.WriteControl(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), + time.Now().Add(5*time.Second)) + _ = conn.Close() + })) + t.Cleanup(srv.Close) + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + t.Setenv(constants.APIServiceOverrideEnvVarName+"BUILDLOG_STREAMER", wsURL) + + blog := New(strfmt.UUID("00000000-0000-0000-0000-000000000000"), buildplan.ArtifactIDMap{}, "") + + err := blog.Wait(context.Background()) + require.Error(t, err) + assert.Truef(t, buildlogstream.IsStreamDenied(err), "a soft-close with no frames must be recognized as a denial, got: %v", err) +} diff --git a/pkg/runtime/setup.go b/pkg/runtime/setup.go index 322fad1203..ded39d5324 100644 --- a/pkg/runtime/setup.go +++ b/pkg/runtime/setup.go @@ -29,6 +29,7 @@ import ( "github.com/ActiveState/cli/internal/svcctl" "github.com/ActiveState/cli/internal/unarchiver" "github.com/ActiveState/cli/pkg/buildplan" + "github.com/ActiveState/cli/pkg/platform/api/buildlogstream" "github.com/ActiveState/cli/pkg/platform/api/buildplanner/types" "github.com/ActiveState/cli/pkg/platform/model" "github.com/ActiveState/cli/pkg/runtime/events" @@ -303,6 +304,14 @@ func (s *setup) update() error { // Wait for build to finish if !s.buildplan.IsBuildReady() && len(s.toBuild) > 0 { if err := blog.Wait(context.Background()); err != nil { + if buildlogstream.IsStreamDenied(err) { + if s.opts.AuthToken == "" { + return locale.WrapExternalError(err, "err_buildlog_stream_denied_unauthenticated", + "Could not monitor in-progress build. Please authenticate by running '[ACTIONABLE]state auth[/RESET]' and try again.") + } + return locale.WrapExternalError(err, "err_buildlog_stream_denied_unauthorized", + "Could not monitor in-progress build. If this is a private project, make sure your account has access to it.") + } return errs.Wrap(err, "errors occurred during buildlog streaming") } } From b1056861f1274d362c5c0e6a0bfb1f1c9ce82b19 Mon Sep 17 00:00:00 2001 From: mitchell Date: Tue, 14 Jul 2026 11:11:55 -0400 Subject: [PATCH 07/15] ENG-1979: Fail cleanly when the state publish --build directory is missing Running `state publish --build` against a non-existent source directory crashed with a nil-pointer panic instead of reporting the bad path. Two fixes: - Validate the --build directory exists up front and return an InputError naming the offending path, before any metadata resolution, key fetch, or wheel build. - Fix buildWrappedArtifact's error path: the deferred cleanup called the named `cleanup` return, which the `return "", nil, err` statements had already set to nil, so it called a nil function value and panicked. The deferred cleanup now uses a local closure, so an in-build failure returns its real error instead of crashing. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/runners/publish/build.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/runners/publish/build.go b/internal/runners/publish/build.go index 4c2881534c..93dae93168 100644 --- a/internal/runners/publish/build.go +++ b/internal/runners/publish/build.go @@ -28,6 +28,9 @@ func (r *Runner) generateEncryptedArtifact(params *Params) (cleanup func(), rerr if r.project == nil { return nil, locale.NewInputError("err_publish_build_no_project", "The '[ACTIONABLE]--build[/RESET]' flag requires a project so the organization can be determined.") } + if !fileutils.DirExists(params.Build) { + return nil, locale.NewInputError("err_publish_build_dir_not_found", "The '[ACTIONABLE]--build[/RESET]' source directory does not exist: [ACTIONABLE]{{.V0}}[/RESET]", params.Build) + } meta, err := wheel.ResolveMetadata(params.Build, wheel.Metadata{Name: params.Name, Version: params.Version}) if err != nil { @@ -95,12 +98,13 @@ func buildWrappedArtifact(srcDir string, meta wheel.Metadata, key []byte, keyID if err != nil { return "", nil, errs.Wrap(err, "Could not create temp dir") } - cleanup = func() { _ = os.RemoveAll(tmpDir) } + removeTmpDir := func() { _ = os.RemoveAll(tmpDir) } defer func() { if rerr != nil { - cleanup() + removeTmpDir() } }() + cleanup = removeTmpDir wheelPath, err := wheel.Pack(srcDir, meta, tmpDir) if err != nil { From 3198fbe1bddbd1c09470b09d4457f0e0ac12a711 Mon Sep 17 00:00:00 2001 From: mitchell Date: Tue, 14 Jul 2026 13:15:55 -0400 Subject: [PATCH 08/15] Update activestate.yaml --- activestate.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activestate.yaml b/activestate.yaml index 67156608f0..c3549714f3 100644 --- a/activestate.yaml +++ b/activestate.yaml @@ -1,4 +1,4 @@ -project: https://platform.activestate.com/ActiveState/cli?branch=main&commitID=9eee7512-b2ab-4600-b78b-ab0cf2e817d8 +project: https://platform.activestate.com/ActiveState/cli?branch=main&commitID=bac333be-be69-4334-bfeb-f326f49af681 constants: - name: CLI_BUILDFLAGS value: -ldflags="-s -w" From cfd76797f03c19d1cd3e1ff3e4c0cbd222c7ba8b Mon Sep 17 00:00:00 2001 From: mitchell Date: Tue, 14 Jul 2026 15:45:26 -0400 Subject: [PATCH 09/15] ENG-1984: Fetch the organization key lazily, only when an encrypted artifact is present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checking out a project with no encrypted private ingredients printed a spurious "Could not fetch the organization key" warning whenever a key service was configured, because the key was fetched eagerly regardless of whether the runtime had anything to decrypt. The runtime now receives a key-fetch callback (WithDecryptionKey) instead of the key bytes and invokes it only when it actually encounters an encrypted artifact during unpack. A runtime with no private artifacts never contacts the key service — no spurious warning and no fetch latency. The premature warning is removed; the existing end-of-run notice (skipReporter) already reports any artifacts skipped because the key was unavailable, which is the only case worth surfacing. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/runbits/runtime/runtime.go | 15 ++++++--------- pkg/runtime/decrypt_test.go | 23 +++++++++++++++++++---- pkg/runtime/options.go | 9 ++++----- pkg/runtime/setup.go | 22 ++++++++++++++-------- 4 files changed, 43 insertions(+), 26 deletions(-) diff --git a/internal/runbits/runtime/runtime.go b/internal/runbits/runtime/runtime.go index dcdf29b446..4a7b916177 100644 --- a/internal/runbits/runtime/runtime.go +++ b/internal/runbits/runtime/runtime.go @@ -289,18 +289,15 @@ func Update( } rtOpts = append(rtOpts, runtime.WithCacheSize(prime.Config().GetInt(constants.RuntimeCacheSizeConfigKey))) - // Fetch the organization key for private ingredients, if a key service is configured. + // If a key service is configured, provide a lazy fetch of the organization key upon encountering + // an encrypted private artifact. orgKeyProvider := orgkey.New(prime.Config(), proj.Owner()) if orgKeyProvider.Configured() { defer orgKeyProvider.Close() - key, keyID, err := orgKeyProvider.Key(context.Background()) - if err != nil { - prime.Output().Notice(locale.Tl("warn_orgkey_unavailable", - "[WARNING]Warning:[/RESET] Could not fetch the organization key: {{.V0}}. Encrypted private artifacts will be skipped. Ensure the key is available and run '[ACTIONABLE]state refresh[/RESET]' to try installing them again.", - errs.JoinMessage(err))) - } else { - rtOpts = append(rtOpts, runtime.WithDecryptionKey(key, keyID)) - } + rtOpts = append(rtOpts, runtime.WithDecryptionKey(func() ([]byte, error) { + key, _, err := orgKeyProvider.Key(context.Background()) + return key, err + })) } if isArmPlatform(buildPlan) { diff --git a/pkg/runtime/decrypt_test.go b/pkg/runtime/decrypt_test.go index 14facf07e1..463f5b67d2 100644 --- a/pkg/runtime/decrypt_test.go +++ b/pkg/runtime/decrypt_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/ActiveState/cli/internal/artifactcrypto" + "github.com/ActiveState/cli/internal/errs" "github.com/go-openapi/strfmt" ) @@ -157,7 +158,7 @@ func TestDecryptPayload(t *testing.T) { writeFile(t, filepath.Join(dir, "runtime.json"), []byte(`{"installDir":"."}`)) writeFile(t, filepath.Join(dir, artifactcrypto.PayloadFilename), encryptToBytes(t, payload, key)) - s := &setup{opts: &Opts{OrgKey: key}} + s := &setup{opts: &Opts{OrgKey: func() ([]byte, error) { return key, nil }}} outcome, err := s.decryptPayload("pkg", dir) if err != nil { t.Fatalf("decryptPayload: %v", err) @@ -207,12 +208,26 @@ func TestDecryptPayload(t *testing.T) { } }) + t.Run("key fetch error skips", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, artifactcrypto.PayloadFilename), encryptToBytes(t, payload, key)) + + s := &setup{opts: &Opts{OrgKey: func() ([]byte, error) { return nil, errs.New("key service unreachable") }}} + outcome, err := s.decryptPayload("pkg", dir) + if err != nil { + t.Fatalf("decryptPayload: %v", err) + } + if outcome != decryptSkipped { + t.Fatalf("outcome = %v, want decryptSkipped", outcome) + } + }) + t.Run("wrong key fails closed", func(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, artifactcrypto.PayloadFilename), encryptToBytes(t, payload, key)) wrong := make([]byte, artifactcrypto.KeySize) // all zeros - s := &setup{opts: &Opts{OrgKey: wrong}} + s := &setup{opts: &Opts{OrgKey: func() ([]byte, error) { return wrong, nil }}} _, err := s.decryptPayload("pkg", dir) if err == nil { t.Fatal("expected a wrong-key error, got nil") @@ -222,7 +237,7 @@ func TestDecryptPayload(t *testing.T) { t.Run("plaintext artifact is untouched", func(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "runtime.json"), []byte(`{"installDir":"."}`)) - s := &setup{opts: &Opts{OrgKey: key}} + s := &setup{opts: &Opts{OrgKey: func() ([]byte, error) { return key, nil }}} outcome, err := s.decryptPayload("pkg", dir) if err != nil { t.Fatal(err) @@ -238,7 +253,7 @@ func TestDecryptPayload(t *testing.T) { writeFile(t, filepath.Join(dir, "runtime.json"), []byte(`{"installDir":"installdir"}`)) writeFile(t, filepath.Join(dir, "installdir", artifactcrypto.PayloadFilename), encryptToBytes(t, payload, key)) - s := &setup{opts: &Opts{OrgKey: key}} + s := &setup{opts: &Opts{OrgKey: func() ([]byte, error) { return key, nil }}} outcome, err := s.decryptPayload("pkg", dir) if err != nil { t.Fatalf("decryptPayload: %v", err) diff --git a/pkg/runtime/options.go b/pkg/runtime/options.go index 63431b74a7..4e211c9bc6 100644 --- a/pkg/runtime/options.go +++ b/pkg/runtime/options.go @@ -15,12 +15,11 @@ func WithAuthToken(token string) SetOpt { return func(opts *Opts) { opts.AuthToken = token } } -// WithDecryptionKey supplies the organization AES-256 key (and its id) used to -// decrypt private artifacts during install. -func WithDecryptionKey(key []byte, keyID string) SetOpt { +// WithDecryptionKey supplies a function that lazily fetches the organization +// AES-256 key used to decrypt private artifacts during install. +func WithDecryptionKey(fetch func() ([]byte, error)) SetOpt { return func(opts *Opts) { - opts.OrgKey = key - opts.OrgKeyID = keyID + opts.OrgKey = fetch } } diff --git a/pkg/runtime/setup.go b/pkg/runtime/setup.go index 322fad1203..58eb000ece 100644 --- a/pkg/runtime/setup.go +++ b/pkg/runtime/setup.go @@ -61,11 +61,9 @@ type Opts struct { // the server can authorize the stream. Empty for unauthenticated callers. AuthToken string - // OrgKey is the organization AES-256 key used to decrypt private artifacts - // during install, with OrgKeyID identifying which key it is. Both are empty - // when the runtime has no private ingredients. - OrgKey []byte - OrgKeyID string + // OrgKey lazily fetches the organization AES-256 key used to decrypt private + // artifacts during install. It is nil when no key service is configured. + OrgKey func() ([]byte, error) FromArchive *fromArchive @@ -566,7 +564,15 @@ func (s *setup) decryptPayload(artifactName, unpackPath string) (outcome decrypt } logging.Debug("Detected encrypted payload in artifact %s", artifactName) - if len(s.opts.OrgKey) == 0 { + if s.opts.OrgKey == nil { + return decryptSkipped, nil + } + key, err := s.opts.OrgKey() + if err != nil { + logging.Debug("Could not obtain org key for artifact %s; skipping: %v", artifactName, errs.JoinMessage(err)) + return decryptSkipped, nil + } + if len(key) == 0 { return decryptSkipped, nil } @@ -575,7 +581,7 @@ func (s *setup) decryptPayload(artifactName, unpackPath string) (outcome decrypt if err != nil { return decryptNotEncrypted, errs.Wrap(err, "could not read encrypted payload header") } - if err := header.CheckKey(s.opts.OrgKey); err != nil { + if err := header.CheckKey(key); err != nil { return decryptNotEncrypted, errs.Wrap(err, "org key does not match encrypted artifact %s", artifactName) } @@ -595,7 +601,7 @@ func (s *setup) decryptPayload(artifactName, unpackPath string) (outcome decrypt if err != nil { return decryptNotEncrypted, errs.Wrap(err, "could not open encrypted payload") } - err = artifactcrypto.Decrypt(src, archivePath, s.opts.OrgKey) + err = artifactcrypto.Decrypt(src, archivePath, key) if cerr := src.Close(); cerr != nil { err = errs.Pack(err, errs.Wrap(cerr, "could not close encrypted payload")) } From 89dfe0ed53880d2babc60701bc9e75f663a3e9f3 Mon Sep 17 00:00:00 2001 From: Marc Gutman <43051639+icanhasmath@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:38:04 -0500 Subject: [PATCH 10/15] serve-org-keys: run under cmd (Windows) without bash or openssl (#3839) * Make serve-org-keys work in cmd shell as well as bash Add a batch twin of serve-org-keys constrained to cmd, and constrain the existing bash version to non-cmd shells, so the script resolves per shell the same way install-deps-dev does. * Run in cmd without openssl or bash Add --gen-cert to orgkeyserver.py so it generates its own self-signed cert/key via the cryptography package, removing the openssl dependency that on Windows only comes from git-bash. Update the cmd/batch variant to detect the Python interpreter (python, py -3, python3) and pass --gen-cert instead of shelling out to openssl. - Quote --org in the no-key branches (bash and batch) so org names with spaces or shell metacharacters are passed as data, matching the --key branches. - chmod the generated TLS private key to 0600 so it isn't world-readable. * Drop openssl entirely and have orgkeyserver.py always generate its self-signed cert. - Add CERT_DIR="test/ssl" constant; generate_self_signed(host) writes cert.pem/key.pem there and returns their paths. - Remove --tls-cert/--tls-key/--gen-cert; cert generation is now the default on every run. - Assume cryptography is present (bundled in the runtime); drop the ImportError guard. - Simplify both activestate.yaml variants to just run the script with --org (and optional --key); remove the openssl/mkdir prep. Resolve the cert dir to an absolute path in generate_self_signed(), and print the absolute key_service_ca path on startup so the value is copy-pasteable regardless of cwd. Docstring example updated to an absolute /path/to/test/ssl/cert.pem placeholder. * Bump project runtime commitID Update to a runtime that bundles cryptography, which serve-org-keys' orgkeyserver.py relies on for cert generation. Co-authored-by: Claude Opus 4.8 (1M context) --- activestate.yaml | 45 +++++++++++++++------ scripts/orgkeyserver.py | 88 +++++++++++++++++++++++++++++++++++------ 2 files changed, 110 insertions(+), 23 deletions(-) diff --git a/activestate.yaml b/activestate.yaml index c3549714f3..be165baff3 100644 --- a/activestate.yaml +++ b/activestate.yaml @@ -1,4 +1,4 @@ -project: https://platform.activestate.com/ActiveState/cli?branch=main&commitID=bac333be-be69-4334-bfeb-f326f49af681 +project: https://platform.activestate.com/ActiveState/cli?branch=main&commitID=ce30761b-3c98-4d7a-b737-bcb1beeef87c constants: - name: CLI_BUILDFLAGS value: -ldflags="-s -w" @@ -461,6 +461,7 @@ scripts: value: go run $project.path()/scripts/to-buildexpression/main.go $@ - name: serve-org-keys language: bash + if: ne .Shell "cmd" description: Runs a server that serves org keys for testing encrypted private ingredients value: | org="$1" @@ -470,21 +471,43 @@ scripts: exit 1 fi key="$2" - - echo "Generating HTTPS certificate." - if [ ! -d test/ssl ]; then mkdir test/ssl; fi - openssl req -x509 -newkey rsa:2048 -nodes -days 365 \ - -keyout test/ssl/key.pem -out test/ssl/cert.pem \ - -subj "/CN=localhost" \ - -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" 2> /dev/null - + echo "Starting org key server." if [ -z "$key" ]; then echo "Using random encryption key (see below)" - python3 -- scripts/orgkeyserver.py --tls-cert test/ssl/cert.pem --tls-key test/ssl/key.pem --org $org + python3 -- scripts/orgkeyserver.py --org "$org" else - python3 -- scripts/orgkeyserver.py --tls-cert test/ssl/cert.pem --tls-key test/ssl/key.pem --org "$org" --key "$key" + python3 -- scripts/orgkeyserver.py --org "$org" --key "$key" fi + - name: serve-org-keys + language: batch + if: eq .Shell "cmd" + description: Runs a server that serves org keys for testing encrypted private ingredients + value: | + set "org=%~1" + if "%org%"=="" ( + echo Usage: state run serve-org-keys ^ [^] + echo Error: missing organization name + exit /b 1 + ) + set "key=%~2" + + set "PY=" + where python >nul 2>nul && set "PY=python" + if not defined PY (where py >nul 2>nul && set "PY=py -3") + if not defined PY (where python3 >nul 2>nul && set "PY=python3") + if not defined PY ( + echo Error: no Python interpreter found on PATH ^(tried python, py, python3^). + exit /b 1 + ) + + echo Starting org key server. + if "%key%"=="" ( + echo Using random encryption key ^(see below^) + %PY% scripts\orgkeyserver.py --org "%org%" + ) else ( + %PY% scripts\orgkeyserver.py --org "%org%" --key "%key%" + ) events: - name: activate diff --git a/scripts/orgkeyserver.py b/scripts/orgkeyserver.py index a25cdc6ab1..b57c9d4b38 100644 --- a/scripts/orgkeyserver.py +++ b/scripts/orgkeyserver.py @@ -4,21 +4,19 @@ Serves the organization encryption key over HTTPS at GET /v1/org-key in the contract the State Tool expects. For local testing only; not a shipped artifact. -Generate a self-signed certificate (the SAN must cover the host you connect to): - - openssl req -x509 -newkey rsa:2048 -nodes -days 365 \ - -keyout key.pem -out cert.pem \ - -subj "/CN=localhost" \ - -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" +A self-signed certificate/key pair (SAN covering localhost + 127.0.0.1) is +generated automatically into test/ssl/ on startup, so no openssl binary is +needed. Run: - python3 scripts/orgkeyserver.py --tls-cert cert.pem --tls-key key.pem + python3 scripts/orgkeyserver.py -Point the State Tool at it (the base URL only; the tool appends /v1/org-key): +Point the State Tool at it (the base URL only; the tool appends /v1/org-key). +Use the absolute cert path the server prints on startup, e.g.: state config set privateingredient.key_service_url https://127.0.0.1:8443 - state config set privateingredient.key_service_ca /path/to/cert.pem + state config set privateingredient.key_service_ca /path/to/test/ssl/cert.pem # Optional bearer auth (start the server with --token ): state config set privateingredient.bearer_token_env ORGKEY_TOKEN export ORGKEY_TOKEN= @@ -31,6 +29,7 @@ import base64 import hashlib import json +import os import secrets import ssl import sys @@ -38,6 +37,70 @@ KEY_SIZE = 32 # AES-256 ENDPOINT = "/v1/org-key" +CERT_DIR = "test/ssl" + + +def generate_self_signed(host, cert_dir=CERT_DIR): + """Write a self-signed cert/key pair into cert_dir; return their paths. + + Uses the 'cryptography' package (bundled in the project runtime) so no + external openssl binary is required. + """ + import datetime + import ipaddress + + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + + cert_dir = os.path.abspath(cert_dir) + cert_path = os.path.join(cert_dir, "cert.pem") + key_path = os.path.join(cert_dir, "key.pem") + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]) + dns_names = ["localhost"] + ip_addrs = ["127.0.0.1"] + try: + ipaddress.ip_address(host) + if host not in ip_addrs: + ip_addrs.append(host) + except ValueError: + if host not in dns_names: + dns_names.append(host) + alt_names = [x509.DNSName(n) for n in dns_names] + alt_names += [x509.IPAddress(ipaddress.ip_address(ip)) for ip in ip_addrs] + now = datetime.datetime.utcnow() + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(minutes=1)) + .not_valid_after(now + datetime.timedelta(days=365)) + .add_extension(x509.SubjectAlternativeName(alt_names), critical=False) + .sign(key, hashes.SHA256()) + ) + + if cert_dir: + os.makedirs(cert_dir, exist_ok=True) + with open(key_path, "wb") as f: + f.write(key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + )) + # Restrict the private key to owner-only; no-op-ish on Windows. + try: + os.chmod(key_path, 0o600) + except OSError: + pass + with open(cert_path, "wb") as f: + f.write(cert.public_bytes(serialization.Encoding.PEM)) + + return cert_path, key_path def build_contract(org, key_id, raw_key): @@ -81,8 +144,6 @@ def parse_key(encoded): def main(): p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - p.add_argument("--tls-cert", required=True, help="server TLS certificate (PEM)") - p.add_argument("--tls-key", required=True, help="server TLS private key (PEM)") p.add_argument("--org", default="ActiveState-CLI-Testing", help="organization the key belongs to; must match the project owner") p.add_argument("--key", help="base64-encoded 32-byte AES key; generated and printed if omitted") @@ -98,9 +159,12 @@ def main(): raw_key = secrets.token_bytes(KEY_SIZE) print("--key", base64.standard_b64encode(raw_key).decode("ascii"), file=sys.stderr) + cert_path, key_path = generate_self_signed(args.host) + print("key_service_ca", cert_path, file=sys.stderr) + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.minimum_version = ssl.TLSVersion.TLSv1_2 - ctx.load_cert_chain(certfile=args.tls_cert, keyfile=args.tls_key) + ctx.load_cert_chain(certfile=cert_path, keyfile=key_path) handler = make_handler(build_contract(args.org, args.key_id, raw_key), args.token) httpd = HTTPServer((args.host, args.port), handler) From 48ea4384acc900b6ee1bb83c184e21f66532c63d Mon Sep 17 00:00:00 2001 From: mitchell Date: Wed, 15 Jul 2026 12:16:31 -0400 Subject: [PATCH 11/15] ENG-1984: Surface the key-fetch failure reason in the skipped-artifacts notice When private artifacts are skipped because the organization key could not be obtained, the end-of-run notice now includes the underlying reason (e.g. the connection or certificate error) instead of leaving it in the debug log. The fetch is memoized, so retrieving the reason costs no additional key-service call. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/runbits/runtime/runtime.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/runbits/runtime/runtime.go b/internal/runbits/runtime/runtime.go index 4a7b916177..a3aef6f889 100644 --- a/internal/runbits/runtime/runtime.go +++ b/internal/runbits/runtime/runtime.go @@ -309,9 +309,15 @@ func Update( } if len(skipped.names) > 0 { + // The fetch is memoized, so this returns the error that caused the skip + // without contacting the key service again. + reason := "" + if _, _, err := orgKeyProvider.Key(context.Background()); err != nil { + reason = ": " + errs.JoinMessage(err) + } prime.Output().Notice(locale.Tl("warn_private_artifacts_skipped", - "[WARNING]Warning:[/RESET] These private packages were skipped because the organization key was unavailable: {{.V0}}. Ensure the key is available and run '[ACTIONABLE]state refresh[/RESET]' to try installing them again.", - strings.Join(skipped.names, ", "))) + "[WARNING]Warning:[/RESET] These private packages were skipped because the organization key was unavailable{{.V1}}. Private packages: {{.V0}}. Ensure the key is available and run '[ACTIONABLE]state refresh[/RESET]' to try installing them again.", + strings.Join(skipped.names, ", "), reason)) } return rt, nil From a8097b88a839128e52dfb8c784e6638f638496f2 Mon Sep 17 00:00:00 2001 From: mitchell Date: Thu, 16 Jul 2026 12:18:42 -0400 Subject: [PATCH 12/15] ENG-1940: Finish an in-progress build when the build-log stream is unavailable When the build-log-streamer WebSocket is denied for an in-progress build, complete the build without it: poll the build to completion, read the resolved artifact download URLs from a re-fetched commit, and drive the existing download/install path off them. A build that fails during polling is surfaced as a failure. The runtime gains a WithBuildPlanPoller option -- a caller-supplied closure, so pkg/runtime takes no buildplanner dependency -- which the runbits layer wires for in-progress builds. Together with the graceful-degradation change this sits on, a denied stream no longer stops state checkout / install from completing. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/runbits/runtime/runtime.go | 12 +++++ pkg/runtime/options.go | 6 +++ pkg/runtime/setup.go | 65 +++++++++++++++++++++---- pkg/runtime/setup_denial_test.go | 73 +++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 pkg/runtime/setup_denial_test.go diff --git a/internal/runbits/runtime/runtime.go b/internal/runbits/runtime/runtime.go index dcdf29b446..c5b3c20ef8 100644 --- a/internal/runbits/runtime/runtime.go +++ b/internal/runbits/runtime/runtime.go @@ -283,6 +283,18 @@ func Update( q.Set("commitID", commitID.String()) u.RawQuery = q.Encode() rtOpts = append(rtOpts, runtime.WithBuildProgressUrl(u.String())) + // Fallback for when the build-log stream is unavailable. + rtOpts = append(rtOpts, runtime.WithBuildPlanPoller(func() (*buildplan.BuildPlan, error) { + bpm := bpModel.NewBuildPlannerModel(prime.Auth(), prime.SvcModel()) + if err := bpm.WaitForBuild(commitID, proj.Owner(), proj.Name(), nil); err != nil { + return nil, errs.Wrap(err, "Could not wait for the in-progress build to complete") + } + c, err := bpm.FetchCommit(commitID, proj.Owner(), proj.Name(), nil) + if err != nil { + return nil, errs.Wrap(err, "Could not fetch the completed build plan") + } + return c.BuildPlan(), nil + })) } if proj.IsPortable() { rtOpts = append(rtOpts, runtime.WithPortable()) diff --git a/pkg/runtime/options.go b/pkg/runtime/options.go index 63431b74a7..726b86a12a 100644 --- a/pkg/runtime/options.go +++ b/pkg/runtime/options.go @@ -1,6 +1,7 @@ package runtime import ( + "github.com/ActiveState/cli/pkg/buildplan" "github.com/ActiveState/cli/pkg/runtime/events" "github.com/go-openapi/strfmt" ) @@ -15,6 +16,11 @@ func WithAuthToken(token string) SetOpt { return func(opts *Opts) { opts.AuthToken = token } } +// WithBuildPlanPoller polls for a buildplan when the build-log stream is unavailable. +func WithBuildPlanPoller(poll func() (*buildplan.BuildPlan, error)) SetOpt { + return func(opts *Opts) { opts.PollBuildPlan = poll } +} + // WithDecryptionKey supplies the organization AES-256 key (and its id) used to // decrypt private artifacts during install. func WithDecryptionKey(key []byte, keyID string) SetOpt { diff --git a/pkg/runtime/setup.go b/pkg/runtime/setup.go index ded39d5324..abd21f6d78 100644 --- a/pkg/runtime/setup.go +++ b/pkg/runtime/setup.go @@ -62,6 +62,9 @@ type Opts struct { // the server can authorize the stream. Empty for unauthenticated callers. AuthToken string + // PollBuildPlan waits for an in-progress build when the build-log stream is unavailable. + PollBuildPlan func() (*buildplan.BuildPlan, error) + // OrgKey is the organization AES-256 key used to decrypt private artifacts // during install, with OrgKeyID identifying which key it is. Both are empty // when the runtime has no private ingredients. @@ -304,15 +307,12 @@ func (s *setup) update() error { // Wait for build to finish if !s.buildplan.IsBuildReady() && len(s.toBuild) > 0 { if err := blog.Wait(context.Background()); err != nil { - if buildlogstream.IsStreamDenied(err) { - if s.opts.AuthToken == "" { - return locale.WrapExternalError(err, "err_buildlog_stream_denied_unauthenticated", - "Could not monitor in-progress build. Please authenticate by running '[ACTIONABLE]state auth[/RESET]' and try again.") - } - return locale.WrapExternalError(err, "err_buildlog_stream_denied_unauthorized", - "Could not monitor in-progress build. If this is a private project, make sure your account has access to it.") + if !buildlogstream.IsStreamDenied(err) { + return errs.Wrap(err, "errors occurred during buildlog streaming") + } + if err := s.completeWithoutStream(wp); err != nil { + return err } - return errs.Wrap(err, "errors occurred during buildlog streaming") } } @@ -357,6 +357,55 @@ func (s *setup) update() error { return nil } +// completeWithoutStream finishes an in-progress build without the build-log +// stream: it polls the build to completion, reads the resolved artifact +// download URLs, and drives the normal download/install path off them. +func (s *setup) completeWithoutStream(wp *workerpool.WorkerPool) error { + if s.opts.PollBuildPlan == nil { + return errs.New("no build plan poller configured") + } + + logging.Debug("completing the in-progress build without the build-log stream") + resolved, err := s.opts.PollBuildPlan() + if err != nil { + return errs.Wrap(err, "Could not complete the in-progress build without the build-log stream") + } + + toObtain, err := s.resolveDownloads(resolved.Artifacts().ToIDMap()) + if err != nil { + return err + } + for _, a := range toObtain { + wp.Submit(func() error { + if err := s.obtain(a); err != nil { + return errs.Wrap(err, "obtain failed") + } + return nil + }) + } + return nil +} + +// resolveDownloads dresses each still-building artifact with the download URL +// from the completed build plan and returns the artifacts to obtain. Artifacts +// that weren't waiting on the build are left untouched (they were obtained +// already). It errors if a still-building artifact has no resolved URL. +func (s *setup) resolveDownloads(resolved buildplan.ArtifactIDMap) ([]*buildplan.Artifact, error) { + var toObtain []*buildplan.Artifact + for _, a := range s.toUnpack { + if _, building := s.toBuild[a.ArtifactID]; !building { + continue + } + ra, ok := resolved[a.ArtifactID] + if !ok || ra.URL == "" { + return nil, errs.New("completed build plan is missing a download URL for artifact %s", a.ArtifactID.String()) + } + a.SetDownload(ra.URL, ra.Checksum) + toObtain = append(toObtain, a) + } + return toObtain, nil +} + func (s *setup) onArtifactBuildReady(blog *buildlog.BuildLog, artifact *buildplan.Artifact, cb func()) { if _, ok := s.toBuild[artifact.ArtifactID]; !ok { // No need to build, artifact can already be downloaded diff --git a/pkg/runtime/setup_denial_test.go b/pkg/runtime/setup_denial_test.go new file mode 100644 index 0000000000..07ab214010 --- /dev/null +++ b/pkg/runtime/setup_denial_test.go @@ -0,0 +1,73 @@ +package runtime + +import ( + "strings" + "testing" + + "github.com/ActiveState/cli/internal/chanutils/workerpool" + "github.com/ActiveState/cli/internal/errs" + "github.com/ActiveState/cli/pkg/buildplan" + "github.com/go-openapi/strfmt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestResolveDownloads verifies that the non-stream fallback copies download +// URLs from the completed build plan onto the artifacts that were waiting on +// the build, and leaves already-downloadable artifacts alone. +func TestResolveDownloads(t *testing.T) { + building := strfmt.UUID("11111111-1111-1111-1111-111111111111") + notBuilding := strfmt.UUID("22222222-2222-2222-2222-222222222222") + + buildingArt := &buildplan.Artifact{ArtifactID: building} + notBuildingArt := &buildplan.Artifact{ArtifactID: notBuilding} + + s := &setup{ + toUnpack: buildplan.ArtifactIDMap{building: buildingArt, notBuilding: notBuildingArt}, + toBuild: buildplan.ArtifactIDMap{building: buildingArt}, + } + + resolved := buildplan.ArtifactIDMap{ + building: &buildplan.Artifact{ArtifactID: building, URL: "https://dl/building", Checksum: "sha256:abc"}, + notBuilding: &buildplan.Artifact{ArtifactID: notBuilding, URL: "https://dl/other"}, + } + + toObtain, err := s.resolveDownloads(resolved) + require.NoError(t, err) + + require.Len(t, toObtain, 1, "only the still-building artifact needs obtaining") + assert.Equal(t, building, toObtain[0].ArtifactID) + assert.Equal(t, "https://dl/building", buildingArt.URL, "building artifact must get its resolved download URL") + assert.Equal(t, "sha256:abc", buildingArt.Checksum) + assert.Empty(t, notBuildingArt.URL, "an artifact that wasn't being built must be left untouched") +} + +// TestResolveDownloads_MissingURL verifies that a completed build plan missing a +// still-building artifact's URL is an error rather than a silent no-download. +func TestResolveDownloads_MissingURL(t *testing.T) { + building := strfmt.UUID("11111111-1111-1111-1111-111111111111") + buildingArt := &buildplan.Artifact{ArtifactID: building} + + s := &setup{ + toUnpack: buildplan.ArtifactIDMap{building: buildingArt}, + toBuild: buildplan.ArtifactIDMap{building: buildingArt}, + } + resolved := buildplan.ArtifactIDMap{building: &buildplan.Artifact{ArtifactID: building}} // no URL + + _, err := s.resolveDownloads(resolved) + require.Error(t, err) +} + +// TestCompleteWithoutStream_PollError verifies that a build that fails while +// polling (surfaced by the poller) is reported as a failure, not swallowed. +func TestCompleteWithoutStream_PollError(t *testing.T) { + s := &setup{opts: &Opts{ + PollBuildPlan: func() (*buildplan.BuildPlan, error) { + return nil, errs.New("build failed while polling") + }, + }} + err := s.completeWithoutStream(workerpool.New(1)) + require.Error(t, err) + assert.Contains(t, strings.ToLower(errs.JoinMessage(err)), "build failed while polling", + "the underlying build failure must be preserved") +} From a1843c3e0eaa1491e8bbd30916e7a6a7936e4158 Mon Sep 17 00:00:00 2001 From: Marc Gutman <43051639+icanhasmath@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:01:07 -0500 Subject: [PATCH 13/15] ENG-2005-Environment-variable overrides for all config options (#3843) * Add environment-variable overrides for all config options Every registered config option can now be overridden by an environment variable, so config can be applied machine-wide (all users) without a shared config file and without sharing auth. Each option gets a canonical override derived from its key, e.g. "update.info.endpoint" -> ACTIVESTATE_CONFIG_UPDATE_INFO_ENDPOINT. The five options that already had bespoke env vars (ACTIVESTATE_API_HOST, ACTIVESTATE_CLI_ANALYTICS_PIXEL, ACTIVESTATE_NOTIFICATIONS_OVERRIDE, ACTIVESTATE_TEST_UPDATE_URL, ACTIVESTATE_TEST_UPDATE_INFO_URL) keep working as aliases; the canonical variable takes precedence when both are set. Overrides are resolved in config.Instance.Get (env > stored > default) so they actually take effect everywhere, not just in display. Values are coerced to the option's type. Only registered options are eligible, which keeps credentials (e.g. apiToken, not a registered option) out of scope. `state config` now reports the effective value and a Source column showing the overriding env var name, "local", or "default"; JSON output gains source, env, and envVar fields. Co-Authored-By: Claude Opus 4.8 (1M context) * Address PR review: validate env override values; add locale strings - EnvOverride now strictly coerces env values to the option's type and ignores invalid ones (unparseable bool/int, out-of-set enum), so a mis-typed variable falls back to the stored/default value instead of silently becoming a zero value. Mirrors the validation `state config set` already performs. - Add the config_source_local and config_source_default locale strings used by the Source column instead of relying on the fallback path. Co-Authored-By: Claude Opus 4.8 (1M context) * Fix TestList integration test for new Source column The `*` marker that denoted a locally-set value was replaced by the Source column, so `state config` now shows "optin.buildscripts true local" instead of "true*". Update TestList to assert the "local" source (and the new "Source" header) instead of the removed "true*" marker. Co-Authored-By: Claude Opus 4.8 (1M context) * Add integration test for environment as config source TestListEnvSource overrides optin.buildscripts via its canonical env var (ACTIVESTATE_CONFIG_OPTIN_BUILDSCRIPTS) without running `state config set`, then asserts the override takes effect and is reported as coming from the environment. The human-readable table confirms the non-default value; the source is asserted via JSON output ("source":"environment" plus the overriding env var name), which is robust against the table wrapping long variable names. Co-Authored-By: Claude Opus 4.8 (1M context) * Address review feedback from mitchell-as - Fold RegisterOptionWithEnv into RegisterOption via a variadic envAliases parameter; drop the separate function. Keep the note that envAliases are only for legacy env vars without the ACTIVESTATE_CONFIG_* prefix. - Use real, user-facing config options in doc/test examples (api.host, security.prompt.level) instead of the test-only update endpoints. - Trim the Instance.Get comment to just note env precedence; drop the machine-wide framing. - Remove the unnecessary comment in config get. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../internal/notifications/notifications.go | 2 +- cmd/state/main.go | 2 +- internal/analytics/analytics.go | 2 +- internal/config/instance.go | 11 +- internal/locale/locales/en-us.yaml | 6 + internal/mediators/config/registry.go | 110 ++++++++++++++++-- internal/mediators/config/registry_test.go | 98 ++++++++++++++++ internal/runners/config/config.go | 67 +++++++++-- internal/runners/config/env_test.go | 69 +++++++++++ internal/updater/checker.go | 2 +- internal/updater/updater.go | 2 +- pkg/platform/api/settings.go | 2 +- test/integration/config_int_test.go | 41 ++++++- 13 files changed, 385 insertions(+), 29 deletions(-) create mode 100644 internal/mediators/config/registry_test.go create mode 100644 internal/runners/config/env_test.go diff --git a/cmd/state-svc/internal/notifications/notifications.go b/cmd/state-svc/internal/notifications/notifications.go index 19136e29a9..b2555ea723 100644 --- a/cmd/state-svc/internal/notifications/notifications.go +++ b/cmd/state-svc/internal/notifications/notifications.go @@ -25,7 +25,7 @@ import ( ) func init() { - configMediator.RegisterOption(constants.NotificationsURLConfig, configMediator.String, "") + configMediator.RegisterOption(constants.NotificationsURLConfig, configMediator.String, "", constants.NotificationsOverrideEnvVarName) } const ConfigKeyLastReport = "notifications.last_reported" diff --git a/cmd/state/main.go b/cmd/state/main.go index 8ddde10de9..64b17ad955 100644 --- a/cmd/state/main.go +++ b/cmd/state/main.go @@ -97,7 +97,7 @@ func main() { // Configuration options // This should only be used if the config option is not exclusive to one package. configMediator.RegisterOption(constants.OptinBuildscriptsConfig, configMediator.Bool, false) - configMediator.RegisterOption(constants.NotificationsURLConfig, configMediator.String, "") + configMediator.RegisterOption(constants.NotificationsURLConfig, configMediator.String, "", constants.NotificationsOverrideEnvVarName) // Set up our output formatter/writer outFlags := parseOutputFlags(os.Args) diff --git a/internal/analytics/analytics.go b/internal/analytics/analytics.go index 05641be0d1..a1bdfdabf5 100644 --- a/internal/analytics/analytics.go +++ b/internal/analytics/analytics.go @@ -7,7 +7,7 @@ import ( ) func init() { - configMediator.RegisterOption(constants.AnalyticsPixelOverrideConfig, configMediator.String, "") + configMediator.RegisterOption(constants.AnalyticsPixelOverrideConfig, configMediator.String, "", constants.AnalyticsPixelOverrideEnv) } // Dispatcher describes a struct that can send analytics event in the background diff --git a/internal/config/instance.go b/internal/config/instance.go index bfc3979bc4..4d666fa850 100644 --- a/internal/config/instance.go +++ b/internal/config/instance.go @@ -178,11 +178,20 @@ func (i *Instance) rawGet(key string) interface{} { } func (i *Instance) Get(key string) interface{} { + opt := mediator.GetOption(key) + + // An environment variable override takes precedence over any stored or default value. + if mediator.KnownOption(opt) { + if value, _, ok := mediator.EnvOverride(opt); ok { + return value + } + } + result := i.rawGet(key) if result != nil { return result } - if opt := mediator.GetOption(key); mediator.KnownOption(opt) { + if mediator.KnownOption(opt) { return mediator.GetDefault(opt) } return nil diff --git a/internal/locale/locales/en-us.yaml b/internal/locale/locales/en-us.yaml index d7a6d8e8c3..3c1f136aa0 100644 --- a/internal/locale/locales/en-us.yaml +++ b/internal/locale/locales/en-us.yaml @@ -1545,6 +1545,12 @@ value: other: Value default: other: Default +source: + other: Source +config_source_local: + other: local +config_source_default: + other: default vulnerabilities: other: Vulnerabilities (CVEs) dependency_row: diff --git a/internal/mediators/config/registry.go b/internal/mediators/config/registry.go index f6f327f53d..80f7695afc 100644 --- a/internal/mediators/config/registry.go +++ b/internal/mediators/config/registry.go @@ -1,6 +1,24 @@ package config -import "sort" +import ( + "os" + "sort" + "strings" + + "github.com/spf13/cast" +) + +// EnvVarPrefix is prepended to a config key to derive its canonical environment-variable override. +const EnvVarPrefix = "ACTIVESTATE_CONFIG_" + +var envVarReplacer = strings.NewReplacer(".", "_", "-", "_") + +// CanonicalEnvVarName returns the environment variable that overrides the given config key. Every +// registered config option can be overridden this way, e.g. "api.host" maps to +// "ACTIVESTATE_CONFIG_API_HOST". +func CanonicalEnvVarName(key string) string { + return EnvVarPrefix + strings.ToUpper(envVarReplacer.Replace(key)) +} type Type int @@ -25,9 +43,13 @@ var EmptyEvent = func(value interface{}) (interface{}, error) { // Option defines what a config value's name and type should be, along with any get/set events type Option struct { - Name string - Type Type - Default interface{} + Name string + Type Type + Default interface{} + // EnvAliases are additional (legacy/bespoke) environment variables that override this option, in + // addition to its canonical CanonicalEnvVarName. They are kept for backwards compatibility with + // env vars that predate the canonical ACTIVESTATE_CONFIG_* scheme (e.g. ACTIVESTATE_API_HOST). + EnvAliases []string GetEvent Event SetEvent Event isRegistered bool @@ -52,28 +74,92 @@ func NewEnum(options []string, default_ string) *Enums { func GetOption(key string) Option { rule, ok := registry[key] if !ok { - return Option{key, String, "", EmptyEvent, EmptyEvent, false, false} + return Option{key, String, "", nil, EmptyEvent, EmptyEvent, false, false} } return rule } -// Registers a config option without get/set events. -func RegisterOption(key string, t Type, defaultValue interface{}) { - registerOption(key, t, defaultValue, EmptyEvent, EmptyEvent, false) +// RegisterOption registers a config option without get/set events. Any envAliases are additional +// legacy/bespoke environment variables that override the option, in addition to its canonical +// ACTIVESTATE_CONFIG_* variable. They exist only for env vars that predate and do not use the +// canonical prefix (e.g. ACTIVESTATE_API_HOST). +func RegisterOption(key string, t Type, defaultValue interface{}, envAliases ...string) { + registerOption(key, t, defaultValue, envAliases, EmptyEvent, EmptyEvent, false) } // Registers a hidden config option without get/set events. func RegisterHiddenOption(key string, t Type, defaultValue interface{}) { - registerOption(key, t, defaultValue, EmptyEvent, EmptyEvent, true) + registerOption(key, t, defaultValue, nil, EmptyEvent, EmptyEvent, true) } // Registers a config option with get/set events. func RegisterOptionWithEvents(key string, t Type, defaultValue interface{}, get, set Event) { - registerOption(key, t, defaultValue, get, set, false) + registerOption(key, t, defaultValue, nil, get, set, false) +} + +func registerOption(key string, t Type, defaultValue interface{}, envAliases []string, get, set Event, hidden bool) { + registry[key] = Option{key, t, defaultValue, envAliases, get, set, true, hidden} +} + +// EnvVarNames returns every environment variable that can override this option: its canonical +// ACTIVESTATE_CONFIG_* variable first, followed by any legacy aliases. +func EnvVarNames(opt Option) []string { + names := make([]string, 0, len(opt.EnvAliases)+1) + names = append(names, CanonicalEnvVarName(opt.Name)) + names = append(names, opt.EnvAliases...) + return names } -func registerOption(key string, t Type, defaultValue interface{}, get, set Event, hidden bool) { - registry[key] = Option{key, t, defaultValue, get, set, true, hidden} +// EnvOverride returns the effective override value for the option when one of its environment +// variables is currently set to a non-empty, valid value, coerced to the option's type. The second +// return value is the name of the variable in effect; the bool reports whether an override applies. +// +// A variable whose value cannot be strictly coerced to the option's type (an unparseable bool/int +// or an enum value outside the allowed set) is ignored, so a mis-typed variable falls back to the +// stored/default value rather than silently changing behavior to a zero value. +func EnvOverride(opt Option) (interface{}, string, bool) { + for _, name := range EnvVarNames(opt) { + raw, ok := os.LookupEnv(name) + if !ok || raw == "" { + continue + } + if value, valid := coerceToType(opt, raw); valid { + return value, name, true + } + } + return nil, "", false +} + +// coerceToType converts a raw environment-variable string to the option's configured type so that +// callers receive the same Go type they would get from a stored value. The bool result reports +// whether the raw value is valid for the option's type; invalid values must not be applied. +func coerceToType(opt Option, raw string) (interface{}, bool) { + switch opt.Type { + case Bool: + v, err := cast.ToBoolE(raw) + if err != nil { + return nil, false + } + return v, true + case Int: + v, err := cast.ToIntE(raw) + if err != nil { + return nil, false + } + return v, true + case Enum: + if enums, ok := opt.Default.(*Enums); ok { + for _, option := range enums.Options { + if option == raw { + return raw, true + } + } + return nil, false + } + return raw, true + default: // String + return raw, true + } } func KnownOption(rule Option) bool { diff --git a/internal/mediators/config/registry_test.go b/internal/mediators/config/registry_test.go new file mode 100644 index 0000000000..b66829e1c4 --- /dev/null +++ b/internal/mediators/config/registry_test.go @@ -0,0 +1,98 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCanonicalEnvVarName(t *testing.T) { + assert.Equal(t, "ACTIVESTATE_CONFIG_API_HOST", CanonicalEnvVarName("api.host")) + assert.Equal(t, "ACTIVESTATE_CONFIG_AUTOUPDATE", CanonicalEnvVarName("autoupdate")) + assert.Equal(t, "ACTIVESTATE_CONFIG_SECURITY_PROMPT_LEVEL", CanonicalEnvVarName("security.prompt.level")) + assert.Equal(t, "ACTIVESTATE_CONFIG_PRIVATEINGREDIENT_MTLS_CERT", CanonicalEnvVarName("privateingredient.mtls_cert")) +} + +func TestEnvOverrideCanonical(t *testing.T) { + RegisterOption("test.canonical.key", String, "default") + opt := GetOption("test.canonical.key") + + // Not set -> no override. + _, _, ok := EnvOverride(opt) + assert.False(t, ok) + + // Canonical variable applies. + t.Setenv("ACTIVESTATE_CONFIG_TEST_CANONICAL_KEY", "from-canonical") + value, envVar, ok := EnvOverride(opt) + assert.True(t, ok) + assert.Equal(t, "from-canonical", value) + assert.Equal(t, "ACTIVESTATE_CONFIG_TEST_CANONICAL_KEY", envVar) +} + +func TestEnvOverrideAliasAndPrecedence(t *testing.T) { + RegisterOption("test.alias.key", String, "default", "LEGACY_ALIAS_VAR") + opt := GetOption("test.alias.key") + + // A legacy alias applies when the canonical var is unset. + t.Setenv("LEGACY_ALIAS_VAR", "from-alias") + value, envVar, ok := EnvOverride(opt) + assert.True(t, ok) + assert.Equal(t, "from-alias", value) + assert.Equal(t, "LEGACY_ALIAS_VAR", envVar) + + // The canonical variable takes precedence over the alias. + t.Setenv("ACTIVESTATE_CONFIG_TEST_ALIAS_KEY", "from-canonical") + value, envVar, ok = EnvOverride(opt) + assert.True(t, ok) + assert.Equal(t, "from-canonical", value) + assert.Equal(t, "ACTIVESTATE_CONFIG_TEST_ALIAS_KEY", envVar) +} + +func TestEnvOverrideTypeCoercion(t *testing.T) { + RegisterOption("test.coerce.bool", Bool, false) + RegisterOption("test.coerce.int", Int, 0) + + t.Setenv("ACTIVESTATE_CONFIG_TEST_COERCE_BOOL", "true") + v, _, ok := EnvOverride(GetOption("test.coerce.bool")) + assert.True(t, ok) + assert.Equal(t, true, v, "bool env value should be coerced to a real bool") + + t.Setenv("ACTIVESTATE_CONFIG_TEST_COERCE_INT", "42") + v, _, ok = EnvOverride(GetOption("test.coerce.int")) + assert.True(t, ok) + assert.Equal(t, 42, v, "int env value should be coerced to a real int") +} + +func TestEnvOverrideEmptyIgnored(t *testing.T) { + RegisterOption("test.empty.key", String, "default") + t.Setenv("ACTIVESTATE_CONFIG_TEST_EMPTY_KEY", "") + _, _, ok := EnvOverride(GetOption("test.empty.key")) + assert.False(t, ok, "an empty env var should be treated as unset") +} + +func TestEnvOverrideInvalidIgnored(t *testing.T) { + RegisterOption("test.invalid.bool", Bool, false) + RegisterOption("test.invalid.int", Int, 0) + RegisterOption("test.invalid.enum", Enum, NewEnum([]string{"low", "high"}, "low")) + + // An unparseable bool must be ignored rather than coerced to false. + t.Setenv("ACTIVESTATE_CONFIG_TEST_INVALID_BOOL", "notabool") + _, _, ok := EnvOverride(GetOption("test.invalid.bool")) + assert.False(t, ok, "an unparseable bool env var must not apply") + + // An unparseable int must be ignored rather than coerced to 0. + t.Setenv("ACTIVESTATE_CONFIG_TEST_INVALID_INT", "notanint") + _, _, ok = EnvOverride(GetOption("test.invalid.int")) + assert.False(t, ok, "an unparseable int env var must not apply") + + // An enum value outside the allowed set must be ignored. + t.Setenv("ACTIVESTATE_CONFIG_TEST_INVALID_ENUM", "banana") + _, _, ok = EnvOverride(GetOption("test.invalid.enum")) + assert.False(t, ok, "an out-of-set enum env var must not apply") + + // A valid enum value still applies. + t.Setenv("ACTIVESTATE_CONFIG_TEST_INVALID_ENUM", "high") + value, _, ok := EnvOverride(GetOption("test.invalid.enum")) + assert.True(t, ok) + assert.Equal(t, "high", value) +} diff --git a/internal/runners/config/config.go b/internal/runners/config/config.go index 689b40e7ef..b890b56e7f 100644 --- a/internal/runners/config/config.go +++ b/internal/runners/config/config.go @@ -29,11 +29,46 @@ func NewList(prime primeable) (*List, error) { }, nil } +// Where an option's effective value comes from. +const ( + sourceEnvironment = "environment" // overridden by an environment variable + sourceLocal = "local" // set locally by the user (stored in config.db) + sourceDefault = "default" // neither set nor overridden; using the built-in default +) + type structuredConfigData struct { Key string `json:"key"` Value interface{} `json:"value"` Default interface{} `json:"default"` - opt mediator.Option + // Source is where the effective value comes from: "environment", "local", or "default". + Source string `json:"source"` + // EnvVar is the canonical environment variable that can override this key (always present). + EnvVar string `json:"envVar"` + // Env is the name of the environment variable currently overriding this value, if any. + Env string `json:"env,omitempty"` + opt mediator.Option +} + +// effectiveValue returns the value the State Tool will actually use for the option, along with the +// name of the environment variable overriding it (empty when the value comes from stored config or +// the built-in default). +func effectiveValue(cfg *config.Instance, opt mediator.Option) (interface{}, string) { + if envValue, envVar, ok := mediator.EnvOverride(opt); ok { + return envValue, envVar + } + return cfg.Get(opt.Name), "" +} + +// configSource reports where an option's effective value comes from, and the name of the +// environment variable in effect when the source is the environment. +func configSource(cfg *config.Instance, opt mediator.Option) (source string, envVar string) { + if _, name, ok := mediator.EnvOverride(opt); ok { + return sourceEnvironment, name + } + if cfg.IsSet(opt.Name) { + return sourceLocal, "" + } + return sourceDefault, "" } func (c *List) Run() error { @@ -44,11 +79,15 @@ func (c *List) Run() error { var data []structuredConfigData for _, opt := range registered { - configuredValue := cfg.Get(opt.Name) + configuredValue, envVar := effectiveValue(cfg, opt) + source, _ := configSource(cfg, opt) data = append(data, structuredConfigData{ Key: opt.Name, Value: configuredValue, Default: mediator.GetDefault(opt), + Source: source, + EnvVar: mediator.CanonicalEnvVarName(opt.Name), + Env: envVar, opt: opt, }) } @@ -68,12 +107,13 @@ func (c *List) renderUserFacing(configData []structuredConfigData) error { cfg := c.prime.Config() out := c.prime.Output() - tbl := table.New(locale.Ts("key", "value", "default")) + tbl := table.New(locale.Ts("key", "value", "source", "default")) tbl.HideDash = true for _, config := range configData { tbl.AddRow([]string{ fmt.Sprintf("[CYAN]%s[/RESET]", config.Key), renderConfigValue(cfg, config.opt), + renderConfigSource(cfg, config.opt), fmt.Sprintf("[DISABLED]%s[/RESET]", formatValue(config.opt, config.Default)), }) } @@ -87,7 +127,7 @@ func (c *List) renderUserFacing(configData []structuredConfigData) error { } func renderConfigValue(cfg *config.Instance, opt mediator.Option) string { - configured := cfg.Get(opt.Name) + configured, _ := effectiveValue(cfg, opt) var tags []string if opt.Type == mediator.Bool { if configured == true { @@ -98,11 +138,6 @@ func renderConfigValue(cfg *config.Instance, opt mediator.Option) string { } value := formatValue(opt, configured) - if cfg.IsSet(opt.Name) { - tags = append(tags, "[BOLD]") - value = value + "*" - } - if len(tags) > 0 { return fmt.Sprintf("%s%s[/RESET]", strings.Join(tags, ""), value) } @@ -110,6 +145,20 @@ func renderConfigValue(cfg *config.Instance, opt mediator.Option) string { return value } +// renderConfigSource renders the Source column: the environment variable in effect (when overridden +// by the environment), "local" (set by the user), or a de-emphasized "default". +func renderConfigSource(cfg *config.Instance, opt mediator.Option) string { + source, envVar := configSource(cfg, opt) + switch source { + case sourceEnvironment: + return fmt.Sprintf("[BOLD]%s[/RESET]", envVar) + case sourceLocal: + return fmt.Sprintf("[BOLD]%s[/RESET]", locale.Tl("config_source_local", "local")) + default: + return fmt.Sprintf("[DISABLED]%s[/RESET]", locale.Tl("config_source_default", "default")) + } +} + func formatValue(opt mediator.Option, value interface{}) string { switch opt.Type { case mediator.Enum, mediator.String: diff --git a/internal/runners/config/env_test.go b/internal/runners/config/env_test.go new file mode 100644 index 0000000000..6f7dd10722 --- /dev/null +++ b/internal/runners/config/env_test.go @@ -0,0 +1,69 @@ +package config + +import ( + "testing" + + "github.com/ActiveState/cli/internal/config" + configMediator "github.com/ActiveState/cli/internal/mediators/config" + "github.com/ActiveState/cli/internal/testhelpers/outputhelper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestEffectiveValueEnvOverride verifies precedence used by `state config` (list): an environment +// variable override wins over a stored value, which wins over the default. +func TestEffectiveValueEnvOverride(t *testing.T) { + cfg, err := config.New() + require.NoError(t, err) + defer func() { require.NoError(t, cfg.Close()) }() + + configMediator.RegisterOption("test.env.host", configMediator.String, "default-host", "TEST_ENV_HOST_OVERRIDE") + opt := configMediator.GetOption("test.env.host") + + // No env, no stored value -> default, not overridden. + v, envVar := effectiveValue(cfg, opt) + assert.Equal(t, "default-host", v) + assert.Equal(t, "", envVar) + + // Stored value wins when env is not set. + require.NoError(t, cfg.Set("test.env.host", "user-host")) + v, envVar = effectiveValue(cfg, opt) + assert.Equal(t, "user-host", v) + assert.Equal(t, "", envVar) + + // Env override wins over the stored value and reports the source var. + t.Setenv("TEST_ENV_HOST_OVERRIDE", "env-host") + v, envVar = effectiveValue(cfg, opt) + assert.Equal(t, "env-host", v) + assert.Equal(t, "TEST_ENV_HOST_OVERRIDE", envVar) + + // An empty env var is treated as not set, falling back to the stored value. + t.Setenv("TEST_ENV_HOST_OVERRIDE", "") + v, envVar = effectiveValue(cfg, opt) + assert.Equal(t, "user-host", v) + assert.Equal(t, "", envVar) +} + +// TestGetEnvOverride verifies `state config get ` reports the environment override value. +func TestGetEnvOverride(t *testing.T) { + cfg, err := config.New() + require.NoError(t, err) + defer func() { require.NoError(t, cfg.Close()) }() + + configMediator.RegisterOption("test.env.get", configMediator.String, "", "TEST_ENV_GET_OVERRIDE") + require.NoError(t, cfg.Set("test.env.get", "stored-value")) + + outputer := outputhelper.NewCatcher() + get := &Get{outputer, cfg} + + // Without the env var, the stored value is reported. + require.NoError(t, get.Run(GetParams{Key: Key("test.env.get")})) + assert.Contains(t, outputer.CombinedOutput(), "stored-value") + + // With the env var set, the override is reported instead. + t.Setenv("TEST_ENV_GET_OVERRIDE", "env-value") + outputer = outputhelper.NewCatcher() + get = &Get{outputer, cfg} + require.NoError(t, get.Run(GetParams{Key: Key("test.env.get")})) + assert.Contains(t, outputer.CombinedOutput(), "env-value") +} diff --git a/internal/updater/checker.go b/internal/updater/checker.go index b11c2789c0..3d520169c8 100644 --- a/internal/updater/checker.go +++ b/internal/updater/checker.go @@ -35,7 +35,7 @@ var ( ) func init() { - configMediator.RegisterOption(constants.UpdateInfoEndpointConfig, configMediator.String, "") + configMediator.RegisterOption(constants.UpdateInfoEndpointConfig, configMediator.String, "", constants.TestUpdateInfoURLEnvVarName) } type Checker struct { diff --git a/internal/updater/updater.go b/internal/updater/updater.go index b3a7f4bed8..0b5fe7bd44 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -36,7 +36,7 @@ const ( ) func init() { - configMediator.RegisterOption(constants.UpdateEndpointConfig, configMediator.String, "") + configMediator.RegisterOption(constants.UpdateEndpointConfig, configMediator.String, "", constants.TestUpdateURLEnvVarName) } type ErrorInProgress struct{ *locale.LocalizedError } diff --git a/pkg/platform/api/settings.go b/pkg/platform/api/settings.go index 947f6ff367..25e936bf6d 100644 --- a/pkg/platform/api/settings.go +++ b/pkg/platform/api/settings.go @@ -15,7 +15,7 @@ import ( ) func init() { - configMediator.RegisterOption(constants.APIHostConfig, configMediator.String, "") + configMediator.RegisterOption(constants.APIHostConfig, configMediator.String, "", constants.APIHostEnvVarName) } // Service records available api services diff --git a/test/integration/config_int_test.go b/test/integration/config_int_test.go index ea5972d39d..b85c48789e 100644 --- a/test/integration/config_int_test.go +++ b/test/integration/config_int_test.go @@ -96,6 +96,7 @@ func (suite *ConfigIntegrationTestSuite) TestList() { cp := ts.Spawn("config") cp.Expect("Key") cp.Expect("Value") + cp.Expect("Source") cp.Expect("Default") cp.Expect("optin.buildscripts") cp.Expect("false") @@ -108,14 +109,52 @@ func (suite *ConfigIntegrationTestSuite) TestList() { cp = ts.Spawn("config") cp.Expect("Key") cp.Expect("Value") + cp.Expect("Source") cp.Expect("Default") cp.Expect("optin.buildscripts") - cp.Expect("true*") + // The value is set locally (via `state config set`), so the Source column reads "local". + cp.Expect("true") + cp.Expect("local") cp.ExpectExitCode(0) suite.Require().NotContains(cp.Snapshot(), constants.AsyncRuntimeConfig) } +func (suite *ConfigIntegrationTestSuite) TestListEnvSource() { + suite.OnlyRunForTags(tagsuite.Config) + ts := e2e.New(suite.T(), false) + defer ts.Close() + + // optin.buildscripts defaults to false. Override it via its canonical environment variable + // (without ever running `state config set`), so any non-default value proves the environment + // override took effect. + envVar := "ACTIVESTATE_CONFIG_OPTIN_BUILDSCRIPTS" + + // Human-readable table: the value reflects the environment override. + cp := ts.SpawnWithOpts( + e2e.OptArgs("config"), + e2e.OptAppendEnv(envVar+"=true"), + ) + cp.Expect("Key") + cp.Expect("Value") + cp.Expect("Source") + cp.Expect("Default") + cp.Expect("optin.buildscripts") + cp.Expect("true") + cp.ExpectExitCode(0) + + // JSON output reliably exposes the source metadata (the table wraps long env var names). The + // "environment" source and the overriding variable name are both reported. + cp = ts.SpawnWithOpts( + e2e.OptArgs("config", "-o", "json"), + e2e.OptAppendEnv(envVar+"=true"), + ) + cp.Expect(`"source":"environment"`) + cp.Expect(`"env":"` + envVar + `"`) + cp.ExpectExitCode(0) + AssertValidJSON(suite.T(), cp) +} + func (suite *ConfigIntegrationTestSuite) TestAPIHostConfig() { suite.OnlyRunForTags(tagsuite.Config) ts := e2e.New(suite.T(), false) From 11216d8b21d17ff30034193cd7f69ba62a577883 Mon Sep 17 00:00:00 2001 From: mitchell Date: Fri, 17 Jul 2026 14:01:43 -0400 Subject: [PATCH 14/15] Merge pull request #3845 from ActiveState/marcg/CS-2357-remote-installer-uac CS-2357-Declare asInvoker execution level for remote installer --- .gitignore | 6 +++++ activestate.yaml | 23 +++++++++++++++++++ cmd/state-exec/main_windows.go | 3 +++ cmd/state-installer/main_windows.go | 3 +++ cmd/state-mcp/main_windows.go | 3 +++ cmd/state-remote-installer/versioninfo.json | 2 +- internal/assets/contents/asInvoker.manifest | 25 +++++++++++++++++++++ internal/assets/contents/versioninfo.json | 2 +- 8 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 cmd/state-exec/main_windows.go create mode 100644 cmd/state-installer/main_windows.go create mode 100644 cmd/state-mcp/main_windows.go create mode 100644 internal/assets/contents/asInvoker.manifest diff --git a/.gitignore b/.gitignore index 5e61f30094..01a63bde4f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,10 @@ cmd/state/versioninfo.json cmd/state/resource.syso cmd/state-svc/versioninfo.json cmd/state-svc/resource.syso +cmd/state-installer/versioninfo.json +cmd/state-installer/resource.syso +cmd/state-exec/versioninfo.json +cmd/state-exec/resource.syso +cmd/state-mcp/versioninfo.json +cmd/state-mcp/resource.syso test/ssl/ diff --git a/activestate.yaml b/activestate.yaml index be165baff3..d4e08cc354 100644 --- a/activestate.yaml +++ b/activestate.yaml @@ -94,6 +94,9 @@ scripts: if [[ "$GOOS" == "windows" || "$OS" == "Windows_NT" ]]; then go run scripts/versioninfo-generator/main.go version.txt cmd/state/versioninfo.json "State Tool" go run scripts/versioninfo-generator/main.go version.txt cmd/state-svc/versioninfo.json "State Service" + go run scripts/versioninfo-generator/main.go version.txt cmd/state-installer/versioninfo.json "State Installer" + go run scripts/versioninfo-generator/main.go version.txt cmd/state-exec/versioninfo.json "State Executor" + go run scripts/versioninfo-generator/main.go version.txt cmd/state-mcp/versioninfo.json "State MCP" fi - name: build language: bash @@ -136,6 +139,13 @@ scripts: value: | set -e $constants.SET_ENV + + # Generate resource.syso for Windows + if [[ "$GOOS" == "windows" || "$OS" == "Windows_NT" ]]; then + pushd cmd/state-exec > /dev/null + go generate + popd > /dev/null + fi TARGET=$BUILD_TARGET_DIR/$constants.BUILD_EXEC_TARGET echo "Building $TARGET" go build -tags "$GO_BUILD_TAGS" -o $TARGET $constants.CLI_BUILDFLAGS $constants.EXECUTOR_PKGS @@ -146,6 +156,13 @@ scripts: value: | set -e $constants.SET_ENV + + # Generate resource.syso for Windows + if [[ "$GOOS" == "windows" || "$OS" == "Windows_NT" ]]; then + pushd cmd/state-mcp > /dev/null + go generate + popd > /dev/null + fi TARGET=$BUILD_TARGET_DIR/$constants.BUILD_MCP_TARGET echo "Building $TARGET" go build -tags "$GO_BUILD_TAGS" -o $TARGET $constants.CLI_BUILDFLAGS $constants.MCP_PKGS @@ -175,6 +192,12 @@ scripts: set -e $constants.SET_ENV + # Generate resource.syso for Windows + if [[ "$GOOS" == "windows" || "$OS" == "Windows_NT" ]]; then + pushd cmd/state-installer > /dev/null + go generate + popd > /dev/null + fi go build -tags "$GO_BUILD_TAGS" -o $BUILD_TARGET_DIR/$constants.BUILD_INSTALLER_TARGET $constants.INSTALLER_PKGS - name: build-remote-installer language: bash diff --git a/cmd/state-exec/main_windows.go b/cmd/state-exec/main_windows.go new file mode 100644 index 0000000000..1765eedbf5 --- /dev/null +++ b/cmd/state-exec/main_windows.go @@ -0,0 +1,3 @@ +//go:generate goversioninfo + +package main diff --git a/cmd/state-installer/main_windows.go b/cmd/state-installer/main_windows.go new file mode 100644 index 0000000000..1765eedbf5 --- /dev/null +++ b/cmd/state-installer/main_windows.go @@ -0,0 +1,3 @@ +//go:generate goversioninfo + +package main diff --git a/cmd/state-mcp/main_windows.go b/cmd/state-mcp/main_windows.go new file mode 100644 index 0000000000..1765eedbf5 --- /dev/null +++ b/cmd/state-mcp/main_windows.go @@ -0,0 +1,3 @@ +//go:generate goversioninfo + +package main diff --git a/cmd/state-remote-installer/versioninfo.json b/cmd/state-remote-installer/versioninfo.json index 56871e7d53..4048639c5e 100644 --- a/cmd/state-remote-installer/versioninfo.json +++ b/cmd/state-remote-installer/versioninfo.json @@ -39,5 +39,5 @@ } }, "IconPath": "../../internal/assets/contents/icon.ico", - "ManifestPath": "" + "ManifestPath": "../../internal/assets/contents/asInvoker.manifest" } \ No newline at end of file diff --git a/internal/assets/contents/asInvoker.manifest b/internal/assets/contents/asInvoker.manifest new file mode 100644 index 0000000000..b9bbfd462e --- /dev/null +++ b/internal/assets/contents/asInvoker.manifest @@ -0,0 +1,25 @@ + + + + + + + + + + + diff --git a/internal/assets/contents/versioninfo.json b/internal/assets/contents/versioninfo.json index fc06be7342..e38c63334a 100644 --- a/internal/assets/contents/versioninfo.json +++ b/internal/assets/contents/versioninfo.json @@ -37,5 +37,5 @@ } }, "IconPath": "../../internal/assets/contents/icon.ico", - "ManifestPath": "" + "ManifestPath": "../../internal/assets/contents/asInvoker.manifest" } \ No newline at end of file From 75c93f5c48bb61b451952814eb30a90b327e08e8 Mon Sep 17 00:00:00 2001 From: mitchell Date: Fri, 17 Jul 2026 14:03:22 -0400 Subject: [PATCH 15/15] Update changelog.md --- changelog.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/changelog.md b/changelog.md index a699c53aaa..dca5b57c95 100644 --- a/changelog.md +++ b/changelog.md @@ -24,11 +24,16 @@ and this project adheres to - `privateingredient.bearer_token_env`: name of an environment variable to read a bearer token from. - `privateingredient.bearer_token_file`: path to a file to read a bearer token from. - `privateingredient.cache_key_on_disk`: whether to cache the fetched key on disk for headless or offline reuse. +- `state config` now shows where a configured value comes from (set locally, set by environment variable, or the default). + - All configuration keys can be overridden by a corresponding `ACTIVESTATE_CONFIG_*` environment variable, where all + characters are in upper-case, and '.' is replaced with '_'. For example, the "security.prompt.level" key is + controlled using `ACTIVESTATE_CONFIG_SECURITY_PROMPT_LEVEL`. ### Fixed - Fixed occasional panic due to a concurrent map read/write during runtime setup. - Fixed `state clean cache` from accidentally deleting State Tool binaries on Windows. +- Fixed unnecessary Windows installer UAC elevation prompt. ### Security