hop: initial project state
This commit is contained in:
+123
-5
@@ -7,6 +7,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -14,7 +15,8 @@ const usageText = `Hop — prompt-native version control
|
||||
|
||||
Usage:
|
||||
hop init [path]
|
||||
hop prompt [--from STATE] [--agent NAME] "instruction"
|
||||
hop begin [--agent NAME] [--session ID] [--from STATE] (--stdin | --heredoc | "instruction")
|
||||
hop prompt [--from STATE] [--agent NAME] (--stdin | --heredoc | "instruction")
|
||||
hop checkpoint STATE
|
||||
hop check STATE -- COMMAND [ARG...]
|
||||
hop propose [--summary TEXT] STATE
|
||||
@@ -34,9 +36,13 @@ Usage:
|
||||
Add --json anywhere for machine-readable output.
|
||||
`
|
||||
|
||||
const Version = "0.1.0-alpha.1"
|
||||
const Version = "0.1.0-alpha.2"
|
||||
|
||||
func RunCLI(args []string, stdout, stderr io.Writer) int {
|
||||
return RunCLIWithInput(args, os.Stdin, stdout, stderr)
|
||||
}
|
||||
|
||||
func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
jsonOutput, args := removeFlag(args, "--json")
|
||||
if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
|
||||
fmt.Fprint(stdout, usageText)
|
||||
@@ -80,6 +86,64 @@ func RunCLI(args []string, stdout, stderr io.Writer) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
if command == "begin" {
|
||||
fs := flag.NewFlagSet("begin", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
from := fs.String("from", "", "continue from an explicit Hop state")
|
||||
sessionDefault := os.Getenv("CODEX_THREAD_ID")
|
||||
session := fs.String("session", sessionDefault, "stable interactive-agent session ID")
|
||||
agentDefault := os.Getenv("HOP_AGENT")
|
||||
if agentDefault == "" {
|
||||
if sessionDefault != "" {
|
||||
agentDefault = "codex"
|
||||
} else {
|
||||
agentDefault = "agent"
|
||||
}
|
||||
}
|
||||
agent := fs.String("agent", agentDefault, "agent or harness name")
|
||||
stdinPrompt := fs.Bool("stdin", false, "read exact prompt bytes from stdin")
|
||||
heredocPrompt := fs.Bool("heredoc", false, "read prompt from stdin and remove one shell-added final newline")
|
||||
if err := fs.Parse(commandArgs); err != nil {
|
||||
return 2
|
||||
}
|
||||
message, err := promptMessage(stdin, fs.Args(), *stdinPrompt, *heredocPrompt)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "hop begin: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
|
||||
service, err := OpenProject(".")
|
||||
initialized := false
|
||||
if errors.Is(err, ErrNotHopProject) {
|
||||
service, _, err = InitProject(ctx, ".")
|
||||
initialized = err == nil
|
||||
}
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
defer service.Close()
|
||||
|
||||
result, err := service.BeginPrompt(ctx, message, *from, *agent, *session)
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
begin := BeginResult{PromptResult: result, Initialized: initialized, SessionID: *session}
|
||||
if jsonOutput {
|
||||
writeJSON(stdout, map[string]any{"ok": true, "data": begin})
|
||||
} else {
|
||||
writeRedactionNotice(stderr, result.Redactions)
|
||||
if initialized {
|
||||
fmt.Fprintf(stdout, "Initialized Hop at %s\n", service.Root)
|
||||
}
|
||||
if result.Checkpoint != nil {
|
||||
fmt.Fprintf(stdout, "Checkpointed %s before the follow-up\n", result.Checkpoint.ID)
|
||||
}
|
||||
fmt.Fprintf(stdout, "Captured prompt state %s before project effects\nWorkspace: %s\n", result.Prompt.ID, result.Workspace)
|
||||
fmt.Fprintf(stdout, "Use HOP_STATE_ID=%s HOP_TASK_ID=%s HOP_ATTEMPT_ID=%s for this turn.\n", result.Prompt.ID, result.Task.ID, result.Attempt.ID)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
service, err := OpenProject(".")
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
@@ -93,20 +157,23 @@ func RunCLI(args []string, stdout, stderr io.Writer) int {
|
||||
fs.SetOutput(stderr)
|
||||
from := fs.String("from", "", "continue an existing attempt")
|
||||
agent := fs.String("agent", "", "agent or harness name")
|
||||
stdinPrompt := fs.Bool("stdin", false, "read exact prompt bytes from stdin")
|
||||
heredocPrompt := fs.Bool("heredoc", false, "read prompt from stdin and remove one shell-added final newline")
|
||||
if err := fs.Parse(commandArgs); err != nil {
|
||||
return 2
|
||||
}
|
||||
if len(fs.Args()) != 1 || strings.TrimSpace(fs.Args()[0]) == "" {
|
||||
fmt.Fprintln(stderr, "exactly one non-empty prompt argument is required; quote prompts containing spaces")
|
||||
message, err := promptMessage(stdin, fs.Args(), *stdinPrompt, *heredocPrompt)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "hop prompt: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
message := fs.Args()[0]
|
||||
result, err := service.CreatePrompt(ctx, message, *from, *agent)
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
value = result
|
||||
if !jsonOutput {
|
||||
writeRedactionNotice(stderr, result.Redactions)
|
||||
if result.Checkpoint != nil {
|
||||
fmt.Fprintf(stdout, "Checkpointed %s before the follow-up\n", result.Checkpoint.ID)
|
||||
}
|
||||
@@ -452,6 +519,57 @@ func splitOptionalCommand(args []string) (string, []string, bool) {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
const maxPromptBytes = 16 << 20
|
||||
|
||||
func promptMessage(stdin io.Reader, args []string, rawStdin, heredoc bool) (string, error) {
|
||||
if rawStdin && heredoc {
|
||||
return "", errors.New("use only one of --stdin or --heredoc")
|
||||
}
|
||||
if !rawStdin && !heredoc {
|
||||
if len(args) != 1 || strings.TrimSpace(args[0]) == "" {
|
||||
return "", errors.New("provide exactly one non-empty prompt argument, or use --stdin/--heredoc")
|
||||
}
|
||||
return args[0], nil
|
||||
}
|
||||
if len(args) != 0 {
|
||||
return "", errors.New("do not combine a prompt argument with --stdin or --heredoc")
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(stdin, maxPromptBytes+1))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read prompt from stdin: %w", err)
|
||||
}
|
||||
if len(data) > maxPromptBytes {
|
||||
return "", fmt.Errorf("prompt exceeds %d bytes", maxPromptBytes)
|
||||
}
|
||||
message := string(data)
|
||||
if heredoc {
|
||||
if strings.HasSuffix(message, "\r\n") {
|
||||
message = strings.TrimSuffix(message, "\r\n")
|
||||
} else {
|
||||
message = strings.TrimSuffix(message, "\n")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(message) == "" {
|
||||
return "", errors.New("prompt text is required")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func writeRedactionNotice(w io.Writer, redactions []PromptRedaction) {
|
||||
total := 0
|
||||
for _, redaction := range redactions {
|
||||
total += redaction.Count
|
||||
}
|
||||
if total == 0 {
|
||||
return
|
||||
}
|
||||
noun := "credentials"
|
||||
if total == 1 {
|
||||
noun = "credential"
|
||||
}
|
||||
fmt.Fprintf(w, "Warning: redacted %d potential %s before storing the prompt.\n", total, noun)
|
||||
}
|
||||
|
||||
func writeJSON(w io.Writer, value any) {
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", " ")
|
||||
|
||||
+97
-24
@@ -15,7 +15,7 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const schemaVersion = 1
|
||||
const schemaVersion = 2
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("hop: not found")
|
||||
@@ -137,20 +137,21 @@ type migration struct {
|
||||
statements []string
|
||||
}
|
||||
|
||||
var migrations = []migration{{
|
||||
version: 1,
|
||||
statements: []string{
|
||||
`CREATE TABLE IF NOT EXISTS meta (
|
||||
var migrations = []migration{
|
||||
{
|
||||
version: 1,
|
||||
statements: []string{
|
||||
`CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
) STRICT`,
|
||||
`CREATE TABLE IF NOT EXISTS tasks (
|
||||
`CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
) STRICT`,
|
||||
`CREATE TABLE IF NOT EXISTS states (
|
||||
`CREATE TABLE IF NOT EXISTS states (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED,
|
||||
@@ -164,7 +165,7 @@ var migrations = []migration{{
|
||||
digest TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
) STRICT`,
|
||||
`CREATE TABLE IF NOT EXISTS attempts (
|
||||
`CREATE TABLE IF NOT EXISTS attempts (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
|
||||
agent TEXT NOT NULL,
|
||||
@@ -174,7 +175,7 @@ var migrations = []migration{{
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
) STRICT`,
|
||||
`CREATE TABLE IF NOT EXISTS state_parents (
|
||||
`CREATE TABLE IF NOT EXISTS state_parents (
|
||||
state_id TEXT NOT NULL REFERENCES states(id) ON DELETE CASCADE,
|
||||
parent_state_id TEXT NOT NULL REFERENCES states(id) ON DELETE RESTRICT,
|
||||
role TEXT NOT NULL,
|
||||
@@ -182,7 +183,7 @@ var migrations = []migration{{
|
||||
PRIMARY KEY (state_id, parent_order),
|
||||
UNIQUE (state_id, parent_state_id, role)
|
||||
) STRICT`,
|
||||
`CREATE TABLE IF NOT EXISTS checks (
|
||||
`CREATE TABLE IF NOT EXISTS checks (
|
||||
id TEXT PRIMARY KEY,
|
||||
attempt_id TEXT NOT NULL REFERENCES attempts(id) ON DELETE CASCADE,
|
||||
state_id TEXT REFERENCES states(id) ON DELETE SET NULL,
|
||||
@@ -192,7 +193,7 @@ var migrations = []migration{{
|
||||
output TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
) STRICT`,
|
||||
`CREATE TABLE IF NOT EXISTS events (
|
||||
`CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
|
||||
@@ -201,20 +202,35 @@ var migrations = []migration{{
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
) STRICT`,
|
||||
`CREATE INDEX IF NOT EXISTS states_task_created_idx ON states(task_id, created_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS states_attempt_created_idx ON states(attempt_id, created_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS states_kind_created_idx ON states(kind, created_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS state_parents_parent_idx ON state_parents(parent_state_id, state_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS state_parents_role_idx ON state_parents(state_id, role, parent_order)`,
|
||||
`CREATE INDEX IF NOT EXISTS attempts_task_created_idx ON attempts(task_id, created_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS attempts_status_created_idx ON attempts(status, created_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS checks_attempt_tree_idx ON checks(attempt_id, tree_hash, created_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS checks_tree_idx ON checks(tree_hash, created_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS events_created_idx ON events(created_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS events_task_idx ON events(task_id, created_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS events_attempt_idx ON events(attempt_id, created_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS states_task_created_idx ON states(task_id, created_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS states_attempt_created_idx ON states(attempt_id, created_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS states_kind_created_idx ON states(kind, created_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS state_parents_parent_idx ON state_parents(parent_state_id, state_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS state_parents_role_idx ON state_parents(state_id, role, parent_order)`,
|
||||
`CREATE INDEX IF NOT EXISTS attempts_task_created_idx ON attempts(task_id, created_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS attempts_status_created_idx ON attempts(status, created_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS checks_attempt_tree_idx ON checks(attempt_id, tree_hash, created_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS checks_tree_idx ON checks(tree_hash, created_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS events_created_idx ON events(created_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS events_task_idx ON events(task_id, created_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS events_attempt_idx ON events(attempt_id, created_at, id)`,
|
||||
},
|
||||
},
|
||||
}}
|
||||
{
|
||||
version: 2,
|
||||
statements: []string{
|
||||
`CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||
agent TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
head_state_id TEXT NOT NULL REFERENCES states(id) ON DELETE RESTRICT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (agent, session_id)
|
||||
) STRICT`,
|
||||
`CREATE INDEX IF NOT EXISTS agent_sessions_head_idx ON agent_sessions(head_state_id)`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func (s *Store) migrate(ctx context.Context) error {
|
||||
var current int
|
||||
@@ -337,6 +353,48 @@ func (s *Store) IsInitialized(ctx context.Context) (bool, error) {
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// AgentSessionHead returns the latest prompt state associated with an agent
|
||||
// session. Session heads let interactive agents turn follow-up messages into
|
||||
// descendants without asking the human to carry Hop state IDs between turns.
|
||||
func (s *Store) AgentSessionHead(ctx context.Context, agent, sessionID string) (string, bool, error) {
|
||||
if strings.TrimSpace(agent) == "" || strings.TrimSpace(sessionID) == "" {
|
||||
return "", false, errors.New("hop: agent and session ID are required")
|
||||
}
|
||||
var stateID string
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT head_state_id FROM agent_sessions WHERE agent = ? AND session_id = ?`,
|
||||
agent, sessionID).Scan(&stateID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("read agent session head: %w", err)
|
||||
}
|
||||
return stateID, true, nil
|
||||
}
|
||||
|
||||
// SetAgentSessionHead records the prompt state that should parent the next
|
||||
// message in an interactive agent session.
|
||||
func (s *Store) SetAgentSessionHead(ctx context.Context, agent, sessionID, stateID string) error {
|
||||
if strings.TrimSpace(agent) == "" || strings.TrimSpace(sessionID) == "" || strings.TrimSpace(stateID) == "" {
|
||||
return errors.New("hop: agent, session ID, and state ID are required")
|
||||
}
|
||||
if redacted, findings := RedactPromptSecrets(agent + "\n" + sessionID); len(findings) > 0 || redacted != agent+"\n"+sessionID {
|
||||
return errors.New("hop: refusing to persist a potential credential in agent session metadata")
|
||||
}
|
||||
now := formatTime(time.Now().UTC())
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO agent_sessions(agent, session_id, head_state_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(agent, session_id) DO UPDATE SET
|
||||
head_state_id = excluded.head_state_id,
|
||||
updated_at = excluded.updated_at`,
|
||||
agent, sessionID, stateID, now, now); err != nil {
|
||||
return fmt.Errorf("record agent session head: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateTaskAttemptPrompt atomically creates all three records. The prompt is
|
||||
// durable and the attempt head points to it before the transaction becomes
|
||||
// visible, so an orchestrator may safely deliver the prompt after this returns.
|
||||
@@ -360,6 +418,7 @@ func (s *Store) CreateTaskAttemptPrompt(
|
||||
if task.Title == "" {
|
||||
task.Title = prompt.Prompt
|
||||
}
|
||||
task.Title, _ = RedactPromptSecrets(task.Title)
|
||||
if attempt.ID == "" {
|
||||
attempt.ID = newID("at")
|
||||
}
|
||||
@@ -946,6 +1005,8 @@ func (s *Store) AddCheck(ctx context.Context, check Check) (Check, error) {
|
||||
if check.CreatedAt.IsZero() {
|
||||
check.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
check.Command, _ = redactSecretStrings(check.Command)
|
||||
check.Output, _ = RedactPromptSecrets(check.Output)
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Check{}, fmt.Errorf("begin check creation: %w", err)
|
||||
@@ -1345,6 +1406,15 @@ func insertStateTx(ctx context.Context, tx *sql.Tx, state State, parents []Paren
|
||||
if state.CreatedAt.IsZero() {
|
||||
return errors.New("hop: state creation time is required")
|
||||
}
|
||||
for name, value := range map[string]string{
|
||||
"prompt": state.Prompt,
|
||||
"summary": state.Summary,
|
||||
"agent": state.Agent,
|
||||
} {
|
||||
if redacted, findings := RedactPromptSecrets(value); len(findings) > 0 || redacted != value {
|
||||
return fmt.Errorf("hop: refusing to persist an unredacted credential in state %s", name)
|
||||
}
|
||||
}
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO states(
|
||||
id, kind, task_id, attempt_id, canonical_anchor_id, source_tree, git_commit,
|
||||
@@ -1386,6 +1456,9 @@ func insertEventTx(ctx context.Context, tx *sql.Tx, event Event, payload any) er
|
||||
return fmt.Errorf("encode event payload: %w", err)
|
||||
}
|
||||
}
|
||||
if redacted, findings := RedactPromptSecrets(string(encoded)); len(findings) > 0 || redacted != string(encoded) {
|
||||
return fmt.Errorf("hop: refusing to persist an unredacted credential in event %q", event.Kind)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`INSERT INTO events(id, kind, task_id, attempt_id, state_id, payload_json, created_at)
|
||||
VALUES (?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?)`,
|
||||
|
||||
+18
-6
@@ -86,13 +86,25 @@ type AcceptResult struct {
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type PromptRedaction struct {
|
||||
Kind string `json:"kind"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type PromptResult struct {
|
||||
Prompt State `json:"prompt"`
|
||||
Checkpoint *State `json:"checkpoint,omitempty"`
|
||||
Task Task `json:"task"`
|
||||
Attempt Attempt `json:"attempt"`
|
||||
Workspace string `json:"workspace"`
|
||||
Deliver []string `json:"deliver"`
|
||||
Prompt State `json:"prompt"`
|
||||
Checkpoint *State `json:"checkpoint,omitempty"`
|
||||
Task Task `json:"task"`
|
||||
Attempt Attempt `json:"attempt"`
|
||||
Workspace string `json:"workspace"`
|
||||
Deliver []string `json:"deliver"`
|
||||
Redactions []PromptRedaction `json:"redactions,omitempty"`
|
||||
}
|
||||
|
||||
type BeginResult struct {
|
||||
PromptResult
|
||||
Initialized bool `json:"initialized"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
}
|
||||
|
||||
type EnvironmentResult struct {
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package hop
|
||||
|
||||
import (
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const redactedMarkerPrefix = "[REDACTED:"
|
||||
|
||||
type secretPattern struct {
|
||||
pattern *regexp.Regexp
|
||||
group int
|
||||
kind string
|
||||
priority int
|
||||
}
|
||||
|
||||
type secretCandidate struct {
|
||||
start int
|
||||
end int
|
||||
kind string
|
||||
priority int
|
||||
}
|
||||
|
||||
var promptSecretPatterns = []secretPattern{
|
||||
{
|
||||
pattern: regexp.MustCompile(`(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----`),
|
||||
kind: "private_key",
|
||||
priority: 120,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`(?s)<secret>.*?</secret>`),
|
||||
kind: "explicit_secret",
|
||||
priority: 120,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`\bsk-ant-[A-Za-z0-9_-]{20,}\b`),
|
||||
kind: "api_key",
|
||||
priority: 110,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}\b`),
|
||||
kind: "api_key",
|
||||
priority: 110,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b`),
|
||||
kind: "access_token",
|
||||
priority: 110,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b`),
|
||||
kind: "api_key",
|
||||
priority: 110,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`\bAIza[0-9A-Za-z_-]{30,}\b`),
|
||||
kind: "api_key",
|
||||
priority: 110,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{20,}\b`),
|
||||
kind: "access_token",
|
||||
priority: 110,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`\b(?:AKIA|ASIA|AIDA|AROA|AIPA|ANPA|ANVA|AGPA)[A-Z0-9]{16}\b`),
|
||||
kind: "access_key",
|
||||
priority: 110,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b`),
|
||||
kind: "auth_token",
|
||||
priority: 105,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`(?i)(authorization[ \t]*:[ \t]*(?:bearer|token|basic)[ \t]+)([A-Za-z0-9._~+/=-]{12,})`),
|
||||
group: 2,
|
||||
kind: "auth_token",
|
||||
priority: 100,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`(?i)\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|https?)://([^@\s/]+)@`),
|
||||
group: 1,
|
||||
kind: "connection_credential",
|
||||
priority: 100,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(
|
||||
`(?i)\b[a-z0-9_.-]*(?:api[ _-]?key|access[ _-]?key|secret[ _-]?key|access[ _-]?token|auth[ _-]?token|client[ _-]?secret|api[ _-]?secret|password|passwd|credential|token|secret|key)\b[ \t]*(?:=|:|is\b)[ \t]*["']?([^\s"'` + "`" + `]{12,})`,
|
||||
),
|
||||
group: 1,
|
||||
kind: "credential",
|
||||
priority: 80,
|
||||
},
|
||||
}
|
||||
|
||||
// RedactPromptSecrets removes high-confidence credentials before a prompt is
|
||||
// passed to any durable Hop component. It returns only category/count metadata;
|
||||
// secret values, hashes, and byte positions are deliberately discarded.
|
||||
func RedactPromptSecrets(prompt string) (string, []PromptRedaction) {
|
||||
if prompt == "" {
|
||||
return prompt, nil
|
||||
}
|
||||
|
||||
var candidates []secretCandidate
|
||||
for _, detector := range promptSecretPatterns {
|
||||
for _, match := range detector.pattern.FindAllStringSubmatchIndex(prompt, -1) {
|
||||
index := detector.group * 2
|
||||
if index+1 >= len(match) || match[index] < 0 || match[index+1] <= match[index] {
|
||||
continue
|
||||
}
|
||||
start, end := match[index], match[index+1]
|
||||
if detector.kind == "credential" {
|
||||
for end > start && strings.ContainsRune(".,;:!?)]}", rune(prompt[end-1])) {
|
||||
end--
|
||||
}
|
||||
}
|
||||
if end <= start || strings.HasPrefix(prompt[start:end], redactedMarkerPrefix) {
|
||||
continue
|
||||
}
|
||||
if detector.kind == "credential" && !likelyCredentialValue(prompt[start:end]) {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, secretCandidate{
|
||||
start: start,
|
||||
end: end,
|
||||
kind: detector.kind,
|
||||
priority: detector.priority,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return prompt, nil
|
||||
}
|
||||
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].priority != candidates[j].priority {
|
||||
return candidates[i].priority > candidates[j].priority
|
||||
}
|
||||
if candidates[i].start != candidates[j].start {
|
||||
return candidates[i].start < candidates[j].start
|
||||
}
|
||||
return candidates[i].end > candidates[j].end
|
||||
})
|
||||
selected := make([]secretCandidate, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
overlaps := false
|
||||
for _, existing := range selected {
|
||||
if candidate.start < existing.end && existing.start < candidate.end {
|
||||
overlaps = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !overlaps {
|
||||
selected = append(selected, candidate)
|
||||
}
|
||||
}
|
||||
sort.Slice(selected, func(i, j int) bool { return selected[i].start < selected[j].start })
|
||||
|
||||
counts := make(map[string]int)
|
||||
var redacted strings.Builder
|
||||
cursor := 0
|
||||
for _, candidate := range selected {
|
||||
redacted.WriteString(prompt[cursor:candidate.start])
|
||||
redacted.WriteString(redactedMarkerPrefix)
|
||||
redacted.WriteString(candidate.kind)
|
||||
redacted.WriteByte(']')
|
||||
counts[candidate.kind]++
|
||||
cursor = candidate.end
|
||||
}
|
||||
redacted.WriteString(prompt[cursor:])
|
||||
|
||||
kinds := make([]string, 0, len(counts))
|
||||
for kind := range counts {
|
||||
kinds = append(kinds, kind)
|
||||
}
|
||||
sort.Strings(kinds)
|
||||
findings := make([]PromptRedaction, 0, len(kinds))
|
||||
for _, kind := range kinds {
|
||||
findings = append(findings, PromptRedaction{Kind: kind, Count: counts[kind]})
|
||||
}
|
||||
return redacted.String(), findings
|
||||
}
|
||||
|
||||
func redactSecretStrings(values []string) ([]string, []PromptRedaction) {
|
||||
redacted := make([]string, len(values))
|
||||
counts := make(map[string]int)
|
||||
for index, value := range values {
|
||||
var findings []PromptRedaction
|
||||
redacted[index], findings = RedactPromptSecrets(value)
|
||||
for _, finding := range findings {
|
||||
counts[finding.Kind] += finding.Count
|
||||
}
|
||||
}
|
||||
kinds := make([]string, 0, len(counts))
|
||||
for kind := range counts {
|
||||
kinds = append(kinds, kind)
|
||||
}
|
||||
sort.Strings(kinds)
|
||||
findings := make([]PromptRedaction, 0, len(kinds))
|
||||
for _, kind := range kinds {
|
||||
findings = append(findings, PromptRedaction{Kind: kind, Count: counts[kind]})
|
||||
}
|
||||
return redacted, findings
|
||||
}
|
||||
|
||||
func likelyCredentialValue(value string) bool {
|
||||
if len(value) < 12 {
|
||||
return false
|
||||
}
|
||||
classes := 0
|
||||
var lower, upper, digit, symbol bool
|
||||
counts := make(map[byte]int)
|
||||
for index := 0; index < len(value); index++ {
|
||||
character := value[index]
|
||||
counts[character]++
|
||||
switch {
|
||||
case character >= 'a' && character <= 'z':
|
||||
lower = true
|
||||
case character >= 'A' && character <= 'Z':
|
||||
upper = true
|
||||
case character >= '0' && character <= '9':
|
||||
digit = true
|
||||
default:
|
||||
symbol = true
|
||||
}
|
||||
}
|
||||
for _, present := range []bool{lower, upper, digit, symbol} {
|
||||
if present {
|
||||
classes++
|
||||
}
|
||||
}
|
||||
if classes < 2 {
|
||||
return false
|
||||
}
|
||||
entropy := 0.0
|
||||
length := float64(len(value))
|
||||
for _, count := range counts {
|
||||
probability := float64(count) / length
|
||||
entropy -= probability * math.Log2(probability)
|
||||
}
|
||||
return entropy >= 3.0
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package hop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRedactPromptSecrets(t *testing.T) {
|
||||
apiKey := "sk-proj-" + strings.Repeat("A7", 24)
|
||||
githubToken := "ghp_" + strings.Repeat("b9", 20)
|
||||
bearer := strings.Repeat("abcDEF123._-", 3)
|
||||
generic := strings.Repeat("customKey9", 4)
|
||||
privateKey := "-----BEGIN PRIVATE KEY-----\n" + strings.Repeat("base64material", 4) + "\n-----END PRIVATE KEY-----"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
prompt string
|
||||
secret string
|
||||
marker string
|
||||
}{
|
||||
{"provider API key", "Use " + apiKey + " for this request", apiKey, "[REDACTED:api_key]"},
|
||||
{"provider access token", "token=" + githubToken, githubToken, "[REDACTED:access_token]"},
|
||||
{"authorization header", "Authorization: Bearer " + bearer, bearer, "[REDACTED:auth_token]"},
|
||||
{"contextual generic key", "OPENAI_API_KEY=\"" + generic + "\"", generic, "[REDACTED:credential]"},
|
||||
{"natural language key", "my api key is " + generic, generic, "[REDACTED:credential]"},
|
||||
{"private key", "deploy with\n" + privateKey + "\nnow", privateKey, "[REDACTED:private_key]"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
redacted, findings := RedactPromptSecrets(test.prompt)
|
||||
if strings.Contains(redacted, test.secret) {
|
||||
t.Fatalf("redacted prompt still contains secret: %q", redacted)
|
||||
}
|
||||
if !strings.Contains(redacted, test.marker) {
|
||||
t.Fatalf("redacted prompt = %q, want marker %q", redacted, test.marker)
|
||||
}
|
||||
if len(findings) == 0 {
|
||||
t.Fatal("redaction metadata is empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
plain := "Read OPENAI_API_KEY from the environment; the key is configuration; inspect commit 0123456789abcdef0123456789abcdef."
|
||||
if redacted, findings := RedactPromptSecrets(plain); redacted != plain || len(findings) != 0 {
|
||||
t.Fatalf("plain prompt changed to %q with findings %#v", redacted, findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptSecretNeverReachesDurableProjectBytes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "base.txt"), "base\n")
|
||||
service, _, err := InitProject(context.Background(), root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret := "sk-proj-" + strings.Repeat("NeverPersist7", 4)
|
||||
result, err := service.CreatePrompt(context.Background(), "Use this API key: "+secret, "", "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Redactions) != 1 || result.Redactions[0].Count != 1 {
|
||||
t.Fatalf("redactions = %#v, want one finding", result.Redactions)
|
||||
}
|
||||
if strings.Contains(result.Prompt.Prompt, secret) || !strings.Contains(result.Prompt.Prompt, redactedMarkerPrefix) {
|
||||
t.Fatalf("stored prompt was not redacted: %q", result.Prompt.Prompt)
|
||||
}
|
||||
check, err := service.RunCheck(context.Background(), result.Prompt.ID, []string{
|
||||
"sh", "-c", "printf '%s' '" + secret + "'",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(strings.Join(check.Command, " "), secret) || strings.Contains(check.Output, secret) {
|
||||
t.Fatalf("stored check leaked secret: %#v / %q", check.Command, check.Output)
|
||||
}
|
||||
proposal, err := service.Propose(context.Background(), result.Prompt.ID, "Completed with "+secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(proposal.Proposal.Summary, secret) {
|
||||
t.Fatalf("stored proposal summary leaked secret: %q", proposal.Proposal.Summary)
|
||||
}
|
||||
if err := service.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
needle := []byte(secret)
|
||||
for _, tree := range []string{filepath.Join(root, ".hop"), filepath.Join(root, ".git", "refs", "hop")} {
|
||||
err := filepath.WalkDir(tree, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
contents, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if bytes.Contains(contents, needle) {
|
||||
t.Errorf("secret leaked into durable file %s", path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeginJSONNeverEchoesPromptSecret(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
previousDirectory, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(previousDirectory) })
|
||||
|
||||
secret := "ghp_" + strings.Repeat("NoEcho7", 6)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunCLIWithInput(
|
||||
[]string{"begin", "--agent", "codex", "--session", "secret-test", "--stdin", "--json"},
|
||||
strings.NewReader("Use token="+secret), &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("begin exited %d: %s", code, stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), secret) || strings.Contains(stderr.String(), secret) {
|
||||
t.Fatal("begin output echoed the prompt secret")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), redactedMarkerPrefix) || !strings.Contains(stdout.String(), `"redactions"`) {
|
||||
t.Fatalf("begin JSON omitted redaction evidence: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
+71
-8
@@ -113,7 +113,16 @@ func OpenProject(start string) (*Service, error) {
|
||||
store.Close()
|
||||
return nil, err
|
||||
}
|
||||
if filepath.Clean(recordedRoot) != filepath.Clean(root) {
|
||||
recordedInfo, recordedErr := os.Stat(recordedRoot)
|
||||
rootInfo, rootErr := os.Stat(root)
|
||||
if recordedErr != nil || rootErr != nil {
|
||||
store.Close()
|
||||
if recordedErr != nil {
|
||||
return nil, fmt.Errorf("inspect recorded Hop root: %w", recordedErr)
|
||||
}
|
||||
return nil, fmt.Errorf("inspect discovered Hop root: %w", rootErr)
|
||||
}
|
||||
if !os.SameFile(recordedInfo, rootInfo) {
|
||||
store.Close()
|
||||
return nil, fmt.Errorf("Hop database belongs to %s, not %s", recordedRoot, root)
|
||||
}
|
||||
@@ -137,10 +146,58 @@ func (s *Service) CreatePrompt(ctx context.Context, message, fromStateID, agent
|
||||
if strings.TrimSpace(message) == "" {
|
||||
return PromptResult{}, fmt.Errorf("prompt text is required")
|
||||
}
|
||||
message, redactions := RedactPromptSecrets(message)
|
||||
agent, _ = RedactPromptSecrets(agent)
|
||||
var result PromptResult
|
||||
var err error
|
||||
if fromStateID == "" {
|
||||
return s.createInitialPrompt(ctx, message, agent)
|
||||
result, err = s.createInitialPrompt(ctx, message, agent)
|
||||
} else {
|
||||
result, err = s.createFollowupPrompt(ctx, message, fromStateID, agent)
|
||||
}
|
||||
return s.createFollowupPrompt(ctx, message, fromStateID, agent)
|
||||
if err != nil {
|
||||
return PromptResult{}, err
|
||||
}
|
||||
result.Redactions = redactions
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// BeginPrompt is the interactive-agent entry point. It resolves follow-up
|
||||
// ancestry from a stable harness session ID, creates the prompt state before
|
||||
// project effects, and advances the session pointer for the next turn.
|
||||
func (s *Service) BeginPrompt(ctx context.Context, message, fromStateID, agent, sessionID string) (PromptResult, error) {
|
||||
if strings.TrimSpace(agent) == "" {
|
||||
agent = "agent"
|
||||
}
|
||||
if redactedAgent, findings := RedactPromptSecrets(agent); len(findings) > 0 || redactedAgent != agent {
|
||||
return PromptResult{}, errors.New("hop: refusing to use a potential credential as an agent name")
|
||||
}
|
||||
if redactedSession, findings := RedactPromptSecrets(sessionID); len(findings) > 0 || redactedSession != sessionID {
|
||||
return PromptResult{}, errors.New("hop: refusing to use a potential credential as an agent session ID")
|
||||
}
|
||||
release, err := acquireProjectLock(ctx, s.Root, "prompt")
|
||||
if err != nil {
|
||||
return PromptResult{}, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
if fromStateID == "" && sessionID != "" {
|
||||
if stateID, exists, err := s.Store.AgentSessionHead(ctx, agent, sessionID); err != nil {
|
||||
return PromptResult{}, err
|
||||
} else if exists {
|
||||
fromStateID = stateID
|
||||
}
|
||||
}
|
||||
result, err := s.CreatePrompt(ctx, message, fromStateID, agent)
|
||||
if err != nil {
|
||||
return PromptResult{}, err
|
||||
}
|
||||
if sessionID != "" {
|
||||
if err := s.Store.SetAgentSessionHead(ctx, agent, sessionID, result.Prompt.ID); err != nil {
|
||||
return PromptResult{}, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) createInitialPrompt(ctx context.Context, message, agent string) (PromptResult, error) {
|
||||
@@ -364,14 +421,16 @@ func (s *Service) RunCheck(ctx context.Context, stateID string, argv []string) (
|
||||
if removeErr != nil {
|
||||
return Check{}, removeErr
|
||||
}
|
||||
storedCommand, _ := redactSecretStrings(argv)
|
||||
storedOutput, _ := RedactPromptSecrets(result.Output)
|
||||
check := Check{
|
||||
ID: checkID,
|
||||
AttemptID: attempt.ID,
|
||||
StateID: checkpoint.ID,
|
||||
TreeHash: checkpoint.SourceTree,
|
||||
Command: append([]string(nil), argv...),
|
||||
Command: storedCommand,
|
||||
ExitCode: result.ExitCode,
|
||||
Output: result.Output,
|
||||
Output: storedOutput,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
check, err = s.Store.AddCheck(ctx, check)
|
||||
@@ -411,6 +470,7 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
|
||||
if strings.TrimSpace(summary) == "" {
|
||||
summary = task.Title
|
||||
}
|
||||
summary, _ = RedactPromptSecrets(summary)
|
||||
parents := canonicalizeParents([]Parent{{StateID: attempt.HeadStateID, Role: "run_parent", Order: 0}})
|
||||
proposal := State{
|
||||
ID: newID("r"),
|
||||
@@ -531,13 +591,15 @@ func (s *Service) Accept(ctx context.Context, proposalID string, checkCommand []
|
||||
if removeErr != nil {
|
||||
return AcceptResult{}, removeErr
|
||||
}
|
||||
storedCommand, _ := redactSecretStrings(checkCommand)
|
||||
storedOutput, _ := RedactPromptSecrets(result.Output)
|
||||
check := Check{
|
||||
ID: checkID,
|
||||
AttemptID: proposal.AttemptID,
|
||||
TreeHash: finalTree,
|
||||
Command: append([]string(nil), checkCommand...),
|
||||
Command: storedCommand,
|
||||
ExitCode: result.ExitCode,
|
||||
Output: result.Output,
|
||||
Output: storedOutput,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if check.ExitCode != 0 {
|
||||
@@ -681,6 +743,7 @@ func (s *Service) recordValidationFailure(
|
||||
{StateID: proposal.ID, Role: "run_parent", Order: 0},
|
||||
{StateID: current.ID, Role: "integration_parent", Order: 1},
|
||||
})
|
||||
storedCommand, _ := redactSecretStrings(command)
|
||||
failed := State{
|
||||
ID: newID("f"),
|
||||
Kind: StateFailed,
|
||||
@@ -689,7 +752,7 @@ func (s *Service) recordValidationFailure(
|
||||
CanonicalAnchorID: current.ID,
|
||||
SourceTree: tree,
|
||||
GitCommit: commit,
|
||||
Summary: "Final validation failed: " + shellQuote(command),
|
||||
Summary: "Final validation failed: " + shellQuote(storedCommand),
|
||||
Agent: "hop",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Parents: parents,
|
||||
|
||||
@@ -120,6 +120,76 @@ func TestCLIJSONWorkflow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIBeginAutoInitializesAndContinuesCodexSession(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "base.txt"), "base\n")
|
||||
previousDirectory, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(previousDirectory) })
|
||||
|
||||
firstPrompt := " Add a Desktop-safe file\nwithout changing this spacing. "
|
||||
started := runCLIJSONInputTest(t,
|
||||
[]string{"begin", "--agent", "codex", "--session", "thread-test", "--heredoc", "--json"},
|
||||
firstPrompt+"\n")
|
||||
first := objectField(t, started, "data")
|
||||
if initialized, _ := first["initialized"].(bool); !initialized {
|
||||
t.Fatal("begin did not report automatic Hop initialization")
|
||||
}
|
||||
firstState := objectField(t, first, "prompt")
|
||||
if got := stringField(t, firstState, "prompt"); got != firstPrompt {
|
||||
t.Fatalf("heredoc prompt = %q, want %q", got, firstPrompt)
|
||||
}
|
||||
firstAttempt := objectField(t, first, "attempt")
|
||||
workspace := stringField(t, first, "workspace")
|
||||
writeTestFile(t, filepath.Join(workspace, "desktop.txt"), "first turn\n")
|
||||
|
||||
followupPrompt := "Now preserve this final newline.\n"
|
||||
followed := runCLIJSONInputTest(t,
|
||||
[]string{"begin", "--agent", "codex", "--session", "thread-test", "--stdin", "--json"},
|
||||
followupPrompt)
|
||||
second := objectField(t, followed, "data")
|
||||
if initialized, _ := second["initialized"].(bool); initialized {
|
||||
t.Fatal("follow-up begin unexpectedly reinitialized Hop")
|
||||
}
|
||||
secondState := objectField(t, second, "prompt")
|
||||
if got := stringField(t, secondState, "prompt"); got != followupPrompt {
|
||||
t.Fatalf("stdin prompt = %q, want %q", got, followupPrompt)
|
||||
}
|
||||
secondAttempt := objectField(t, second, "attempt")
|
||||
if stringField(t, secondAttempt, "id") != stringField(t, firstAttempt, "id") {
|
||||
t.Fatal("Codex session follow-up created a new attempt")
|
||||
}
|
||||
if stringField(t, second, "workspace") != workspace {
|
||||
t.Fatal("Codex session follow-up changed workspaces")
|
||||
}
|
||||
checkpoint := objectField(t, second, "checkpoint")
|
||||
if stringField(t, checkpoint, "kind") != string(StateCheckpoint) {
|
||||
t.Fatal("Codex session follow-up did not checkpoint prior effects")
|
||||
}
|
||||
|
||||
service, err := OpenProject(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = service.Close() })
|
||||
head, exists, err := service.Store.AgentSessionHead(context.Background(), "codex", "thread-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists || head != stringField(t, secondState, "id") {
|
||||
t.Fatalf("session head = %q, %v; want second prompt", head, exists)
|
||||
}
|
||||
assertTreeFiles(t, service, stringField(t, checkpoint, "git_commit"), map[string]string{
|
||||
"base.txt": "base\n",
|
||||
"desktop.txt": "first turn\n",
|
||||
})
|
||||
}
|
||||
|
||||
func TestDoctorRejectsCommitTreeAndDigestMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
@@ -559,6 +629,22 @@ func runCLIJSONTest(t *testing.T, args []string) map[string]any {
|
||||
return result
|
||||
}
|
||||
|
||||
func runCLIJSONInputTest(t *testing.T, args []string, input string) map[string]any {
|
||||
t.Helper()
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := RunCLIWithInput(args, strings.NewReader(input), &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("hop %s exited %d\nstdout: %s\nstderr: %s", strings.Join(args, " "), code, stdout.String(), stderr.String())
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
|
||||
t.Fatalf("decode JSON from hop %s: %v\n%s", strings.Join(args, " "), err, stdout.String())
|
||||
}
|
||||
if ok, _ := result["ok"].(bool); !ok {
|
||||
t.Fatalf("hop %s returned non-ok JSON: %#v", strings.Join(args, " "), result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func objectField(t *testing.T, object map[string]any, key string) map[string]any {
|
||||
t.Helper()
|
||||
value, ok := object[key].(map[string]any)
|
||||
|
||||
@@ -26,6 +26,13 @@ func TestInstallSkillBundle(t *testing.T) {
|
||||
t.Fatalf("installed %s is empty", relative)
|
||||
}
|
||||
}
|
||||
metadata, err := os.ReadFile(filepath.Join(result.Path, "agents", "openai.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(metadata), "allow_implicit_invocation: true") {
|
||||
t.Fatal("installed skill does not permit Desktop implicit invocation")
|
||||
}
|
||||
if _, err := InstallSkill(base, false); err == nil || !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("second install error = %v, want existing-skill error", err)
|
||||
}
|
||||
@@ -57,7 +64,7 @@ func TestSkillCLIWorksOutsideHopProject(t *testing.T) {
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
code = RunCLI([]string{"skill", "print"}, &stdout, &stderr)
|
||||
if code != 0 || !strings.Contains(stdout.String(), "Require a durable Hop prompt state") {
|
||||
if code != 0 || !strings.Contains(stdout.String(), "Capture the current prompt first") {
|
||||
t.Fatalf("skill print exited %d\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
|
||||
const maxRecordedOutput = 128 * 1024
|
||||
|
||||
var ErrNotHopProject = errors.New("not inside a Hop project")
|
||||
|
||||
func FindHopRoot(start string) (string, error) {
|
||||
if configured := os.Getenv("HOP_ROOT"); configured != "" {
|
||||
return requireHopRoot(configured)
|
||||
@@ -48,7 +50,7 @@ func FindHopRoot(start string) (string, error) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("not inside a Hop project; run 'hop init' first")
|
||||
return "", fmt.Errorf("%w; run 'hop init' first", ErrNotHopProject)
|
||||
}
|
||||
|
||||
func requireHopRoot(root string) (string, error) {
|
||||
|
||||
Reference in New Issue
Block a user