894 lines
26 KiB
Go
894 lines
26 KiB
Go
package taskloop
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"iop/packages/go/agentconfig"
|
|
"iop/packages/go/agentpolicy"
|
|
"iop/packages/go/agentruntime"
|
|
"iop/packages/go/agenttask"
|
|
)
|
|
|
|
const (
|
|
maxProviderDiagnosticBytes = 32 << 10
|
|
nativeSessionLocatorSchemaVersion = 1
|
|
)
|
|
|
|
// Provider converts one catalog profile into the side-effect-free launch plan
|
|
// required by agenttask.Manager. The executable confinement proof remains the
|
|
// sole process and stdio owner.
|
|
type Provider struct {
|
|
catalog agentconfig.Catalog
|
|
quota QuotaObserver
|
|
now func() time.Time
|
|
}
|
|
|
|
var _ agenttask.ProviderInvoker = (*Provider)(nil)
|
|
|
|
func NewProvider(
|
|
catalog agentconfig.Catalog,
|
|
quotaObservers ...QuotaObserver,
|
|
) (*Provider, error) {
|
|
normalized, err := agentconfig.Normalize(catalog)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("taskloop: normalize provider catalog: %w", err)
|
|
}
|
|
var quota QuotaObserver
|
|
if len(quotaObservers) > 1 {
|
|
return nil, errors.New("taskloop: provider accepts at most one quota observer")
|
|
}
|
|
if len(quotaObservers) == 1 {
|
|
quota = quotaObservers[0]
|
|
}
|
|
return &Provider{catalog: normalized, quota: quota, now: time.Now}, nil
|
|
}
|
|
|
|
func (provider *Provider) Prepare(
|
|
ctx context.Context,
|
|
request agenttask.DispatchRequest,
|
|
) (agenttask.ProviderLaunch, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if request.Permit == nil || request.Confinement == nil {
|
|
return nil, errors.New("taskloop: provider launch requires an admitted confinement")
|
|
}
|
|
resolved, ok := provider.catalog.ResolveProfile(request.Target.ProfileID)
|
|
if !ok {
|
|
return nil, fmt.Errorf(
|
|
"taskloop: selected provider profile %q is not in the catalog",
|
|
request.Target.ProfileID,
|
|
)
|
|
}
|
|
if resolved.Provider.ID != request.Target.ProviderID ||
|
|
resolved.Model.ID != request.Target.ModelID {
|
|
return nil, errors.New("taskloop: selected provider identity does not match the catalog")
|
|
}
|
|
plan := request.Work.Unit.Metadata["plan_path"]
|
|
review := request.Work.Unit.Metadata["review_path"]
|
|
if plan == "" || review == "" {
|
|
return nil, errors.New("taskloop: work unit is missing its active artifact locators")
|
|
}
|
|
prompt := workerPrompt(plan, review)
|
|
binding := request.Confinement.Binding()
|
|
if request.Confinement.Revision() != binding.Revision ||
|
|
binding.ProfileRevision != request.Target.ProfileRevision {
|
|
return nil, errors.New("taskloop: admitted confinement identity mismatch")
|
|
}
|
|
if err := request.Confinement.Validate(binding); err != nil {
|
|
return nil, fmt.Errorf("taskloop: validate admitted confinement: %w", err)
|
|
}
|
|
command, session, err := prepareFreshCatalogCommand(
|
|
resolved,
|
|
request.Target,
|
|
binding,
|
|
request.Workspace.WorkingDir,
|
|
prompt,
|
|
request.Project,
|
|
request.Work,
|
|
"worker",
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &providerLaunch{
|
|
command: command,
|
|
project: request.Project,
|
|
work: request.Work,
|
|
artifact: artifactIdentity(request.Work),
|
|
profileID: resolved.Profile.ID,
|
|
session: session,
|
|
target: request.Target,
|
|
quota: provider.quota,
|
|
now: provider.now,
|
|
}, nil
|
|
}
|
|
|
|
func workerPrompt(plan, review string) string {
|
|
return fmt.Sprintf(
|
|
"This is a direct implementing-worker assignment owned by the task loop. "+
|
|
"Read %s and execute it directly; do not invoke a task-loop dispatcher, "+
|
|
"plan workflow, or code-review workflow. Keep artifact content in English. "+
|
|
"Fill only the implementation-owned sections in %s and stop ready for "+
|
|
"official review. Do not append a review verdict, modify review-only fields, "+
|
|
"write completion metadata, update a roadmap, or archive, move, rename, or "+
|
|
"delete either active artifact.",
|
|
plan,
|
|
review,
|
|
)
|
|
}
|
|
|
|
type providerLaunch struct {
|
|
command agenttask.ConfinementCommand
|
|
project agenttask.ProjectRecord
|
|
work agenttask.WorkRecord
|
|
artifact agenttask.ArtifactID
|
|
profileID string
|
|
session *agenttask.LocatorRecord
|
|
target agenttask.ExecutionTarget
|
|
quota QuotaObserver
|
|
now func() time.Time
|
|
}
|
|
|
|
func (launch *providerLaunch) Command() agenttask.ConfinementCommand {
|
|
return agenttask.ConfinementCommand{
|
|
Name: launch.command.Name,
|
|
Args: append([]string(nil), launch.command.Args...),
|
|
Env: append([]string(nil), launch.command.Env...),
|
|
}
|
|
}
|
|
|
|
func (launch *providerLaunch) BindStarted(
|
|
started agenttask.StartedConfinement,
|
|
) (agenttask.ProviderInvocation, error) {
|
|
if started == nil || started.Child() == nil || started.Child().Process == nil ||
|
|
started.Stdin() == nil || started.Stdout() == nil || started.Stderr() == nil {
|
|
return nil, errors.New("taskloop: confined provider child is incomplete")
|
|
}
|
|
opaque, err := processLocator(started.Child().Process.Pid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
locator := agenttask.LocatorRecord{
|
|
Kind: agenttask.LocatorProcess,
|
|
Opaque: opaque,
|
|
Revision: digestStrings("process-locator", opaque),
|
|
ProjectID: launch.project.ProjectID,
|
|
WorkspaceID: launch.project.WorkspaceID,
|
|
WorkUnitID: launch.work.Unit.ID,
|
|
AttemptID: launch.work.AttemptID,
|
|
}
|
|
locators := []agenttask.LocatorRecord{locator}
|
|
if launch.session != nil {
|
|
locators = append(locators, *launch.session)
|
|
}
|
|
invocation := &providerInvocation{
|
|
started: started,
|
|
project: launch.project,
|
|
work: launch.work,
|
|
artifact: launch.artifact,
|
|
locators: locators,
|
|
target: launch.target,
|
|
quota: launch.quota,
|
|
now: launch.now,
|
|
done: make(chan invocationResult, 1),
|
|
}
|
|
invocation.begin()
|
|
return invocation, nil
|
|
}
|
|
|
|
type invocationResult struct {
|
|
err error
|
|
}
|
|
|
|
type providerInvocation struct {
|
|
started agenttask.StartedConfinement
|
|
project agenttask.ProjectRecord
|
|
work agenttask.WorkRecord
|
|
artifact agenttask.ArtifactID
|
|
locators []agenttask.LocatorRecord
|
|
target agenttask.ExecutionTarget
|
|
quota QuotaObserver
|
|
now func() time.Time
|
|
stdout boundedBuffer
|
|
stderr boundedBuffer
|
|
done chan invocationResult
|
|
once sync.Once
|
|
failureMu sync.Mutex
|
|
failure agentpolicy.AttemptObservation
|
|
}
|
|
|
|
var _ agenttask.ProviderInvocation = (*providerInvocation)(nil)
|
|
var _ agenttask.FailureObservedInvocation = (*providerInvocation)(nil)
|
|
|
|
func (invocation *providerInvocation) begin() {
|
|
invocation.once.Do(func() {
|
|
_ = invocation.started.Stdin().Close()
|
|
var drains sync.WaitGroup
|
|
drains.Add(2)
|
|
go func() {
|
|
defer drains.Done()
|
|
_, _ = io.Copy(&invocation.stdout, invocation.started.Stdout())
|
|
_ = invocation.started.Stdout().Close()
|
|
}()
|
|
go func() {
|
|
defer drains.Done()
|
|
_, _ = io.Copy(&invocation.stderr, invocation.started.Stderr())
|
|
_ = invocation.started.Stderr().Close()
|
|
}()
|
|
go func() {
|
|
waitErr := invocation.started.Child().Wait()
|
|
drains.Wait()
|
|
invocation.done <- invocationResult{err: waitErr}
|
|
close(invocation.done)
|
|
}()
|
|
})
|
|
}
|
|
|
|
func (invocation *providerInvocation) Locators() []agenttask.LocatorRecord {
|
|
return append([]agenttask.LocatorRecord(nil), invocation.locators...)
|
|
}
|
|
|
|
func (invocation *providerInvocation) Wait(
|
|
ctx context.Context,
|
|
) (agenttask.Submission, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
_ = invocation.Cancel(context.WithoutCancel(ctx))
|
|
invocation.recordFailure(
|
|
context.WithoutCancel(ctx),
|
|
&agentruntime.Failure{
|
|
Code: agentruntime.FailureCodeCancelled,
|
|
Retryable: false,
|
|
},
|
|
false,
|
|
)
|
|
return agenttask.Submission{}, ctx.Err()
|
|
case result := <-invocation.done:
|
|
if result.err != nil {
|
|
if ctx.Err() != nil {
|
|
invocation.recordFailure(
|
|
context.WithoutCancel(ctx),
|
|
&agentruntime.Failure{
|
|
Code: agentruntime.FailureCodeCancelled,
|
|
Retryable: false,
|
|
},
|
|
false,
|
|
)
|
|
return agenttask.Submission{}, ctx.Err()
|
|
}
|
|
invocation.recordFailure(
|
|
ctx,
|
|
&agentruntime.Failure{
|
|
Code: agentruntime.FailureCodeProcessExit,
|
|
Retryable: true,
|
|
},
|
|
true,
|
|
)
|
|
return agenttask.Submission{}, errors.New("taskloop: provider exited unsuccessfully")
|
|
}
|
|
return agenttask.Submission{
|
|
ProjectID: invocation.project.ProjectID,
|
|
WorkUnitID: invocation.work.Unit.ID,
|
|
AttemptID: invocation.work.AttemptID,
|
|
ArtifactID: invocation.artifact,
|
|
Ready: true,
|
|
Metadata: map[string]string{
|
|
"provider_output": "bounded-and-discarded",
|
|
},
|
|
Locators: invocation.Locators(),
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
func (invocation *providerInvocation) FailureObservation() agentpolicy.AttemptObservation {
|
|
invocation.failureMu.Lock()
|
|
defer invocation.failureMu.Unlock()
|
|
return invocation.failure.Clone()
|
|
}
|
|
|
|
func (invocation *providerInvocation) recordFailure(
|
|
ctx context.Context,
|
|
failure *agentruntime.Failure,
|
|
observeQuota bool,
|
|
) {
|
|
now := time.Now
|
|
if invocation.now != nil {
|
|
now = invocation.now
|
|
}
|
|
observedAt := now().UTC()
|
|
quota := agentpolicy.CorruptQuotaObservation()
|
|
if observeQuota && invocation.quota != nil {
|
|
if observed, err := invocation.quota.ObserveQuota(
|
|
ctx,
|
|
invocation.target,
|
|
observedAt,
|
|
); err == nil {
|
|
quota = observed
|
|
}
|
|
}
|
|
invocation.failureMu.Lock()
|
|
invocation.failure = agentpolicy.SanitizeAttemptObservation(
|
|
agentpolicy.AttemptObservation{
|
|
ObservedAt: observedAt,
|
|
Quota: quota,
|
|
Failure: agentpolicy.NormalizeFailureObservation(failure),
|
|
},
|
|
observedAt,
|
|
)
|
|
invocation.failureMu.Unlock()
|
|
}
|
|
|
|
func (invocation *providerInvocation) Cancel(ctx context.Context) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
child := invocation.started.Child()
|
|
if child == nil || child.Process == nil {
|
|
return errors.New("taskloop: provider process identity is unavailable")
|
|
}
|
|
if err := child.Process.Signal(syscall.SIGTERM); err != nil &&
|
|
!errors.Is(err, os.ErrProcessDone) {
|
|
if killErr := child.Process.Kill(); killErr != nil &&
|
|
!errors.Is(killErr, os.ErrProcessDone) {
|
|
return errors.Join(err, killErr)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type boundedBuffer struct {
|
|
mu sync.Mutex
|
|
buffer bytes.Buffer
|
|
truncated bool
|
|
}
|
|
|
|
func (buffer *boundedBuffer) Write(data []byte) (int, error) {
|
|
buffer.mu.Lock()
|
|
defer buffer.mu.Unlock()
|
|
remaining := maxProviderDiagnosticBytes - buffer.buffer.Len()
|
|
if remaining > 0 {
|
|
write := len(data)
|
|
if write > remaining {
|
|
write = remaining
|
|
}
|
|
_, _ = buffer.buffer.Write(data[:write])
|
|
}
|
|
if len(data) > remaining {
|
|
buffer.truncated = true
|
|
}
|
|
return len(data), nil
|
|
}
|
|
|
|
type processReference struct {
|
|
PID int `json:"pid"`
|
|
StartToken string `json:"start_token"`
|
|
}
|
|
|
|
func processLocator(pid int) (string, error) {
|
|
if pid <= 0 {
|
|
return "", errors.New("taskloop: provider PID is invalid")
|
|
}
|
|
token, err := processStartToken(pid)
|
|
if err != nil {
|
|
return "", fmt.Errorf("taskloop: capture provider process identity: %w", err)
|
|
}
|
|
encoded, err := json.Marshal(processReference{PID: pid, StartToken: token})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(encoded), nil
|
|
}
|
|
|
|
func processStartToken(pid int) (string, error) {
|
|
if stat, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat"); err == nil {
|
|
text := string(stat)
|
|
if closeIndex := strings.LastIndex(text, ")"); closeIndex >= 0 {
|
|
fields := strings.Fields(text[closeIndex+1:])
|
|
// Fields after the executable name begin at proc field 3. The
|
|
// process start time is field 22, therefore index 19 here.
|
|
if len(fields) > 19 && fields[19] != "" {
|
|
return "proc:" + fields[19], nil
|
|
}
|
|
}
|
|
}
|
|
output, err := exec.Command("ps", "-o", "lstart=", "-p", strconv.Itoa(pid)).Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
token := strings.Join(strings.Fields(string(output)), " ")
|
|
if token == "" {
|
|
return "", errors.New("empty process start token")
|
|
}
|
|
return "ps:" + token, nil
|
|
}
|
|
|
|
func artifactIdentity(work agenttask.WorkRecord) agenttask.ArtifactID {
|
|
return agenttask.ArtifactID(
|
|
digestStrings("artifact", string(work.Unit.ID), string(work.AttemptID)),
|
|
)
|
|
}
|
|
|
|
func expandModelTarget(arguments []string, target string) []string {
|
|
expanded := make([]string, len(arguments))
|
|
for index, argument := range arguments {
|
|
expanded[index] = strings.ReplaceAll(argument, "{{model}}", target)
|
|
}
|
|
return expanded
|
|
}
|
|
|
|
func replaceEnvironment(environment []string, key, value string) []string {
|
|
prefix := key + "="
|
|
out := make([]string, 0, len(environment)+1)
|
|
for _, entry := range environment {
|
|
if !strings.HasPrefix(entry, prefix) {
|
|
out = append(out, entry)
|
|
}
|
|
}
|
|
return append(out, prefix+value)
|
|
}
|
|
|
|
type nativeSessionReference struct {
|
|
SchemaVersion uint32 `json:"schema_version"`
|
|
ProviderID string `json:"provider_id"`
|
|
ModelID string `json:"model_id"`
|
|
ProfileID string `json:"profile_id"`
|
|
ProfileRevision string `json:"profile_revision"`
|
|
SessionID string `json:"session_id"`
|
|
SessionDirectory string `json:"session_directory"`
|
|
ConfinementRevision string `json:"confinement_revision"`
|
|
IsolationID string `json:"isolation_id"`
|
|
IsolationRevision string `json:"isolation_revision"`
|
|
}
|
|
|
|
func prepareFreshCatalogCommand(
|
|
resolved agentconfig.ResolvedProfile,
|
|
target agenttask.ExecutionTarget,
|
|
binding agenttask.ConfinementBinding,
|
|
workingDirectory, prompt string,
|
|
project agenttask.ProjectRecord,
|
|
work agenttask.WorkRecord,
|
|
purpose string,
|
|
) (agenttask.ConfinementCommand, *agenttask.LocatorRecord, error) {
|
|
if err := validateCatalogTarget(resolved, target); err != nil {
|
|
return agenttask.ConfinementCommand{}, nil, err
|
|
}
|
|
args := expandModelTarget(resolved.Profile.Args, resolved.Model.Target)
|
|
var locator *agenttask.LocatorRecord
|
|
if strings.EqualFold(resolved.Provider.ID, "pi") {
|
|
reference, err := newNativeSessionReference(
|
|
resolved,
|
|
target,
|
|
binding,
|
|
project,
|
|
work,
|
|
purpose,
|
|
)
|
|
if err != nil {
|
|
return agenttask.ConfinementCommand{}, nil, err
|
|
}
|
|
args = append(args,
|
|
"--session-id", reference.SessionID,
|
|
"--session-dir", reference.SessionDirectory,
|
|
)
|
|
encoded, err := json.Marshal(reference)
|
|
if err != nil {
|
|
return agenttask.ConfinementCommand{}, nil, err
|
|
}
|
|
record := agenttask.LocatorRecord{
|
|
Kind: agenttask.LocatorSession,
|
|
Opaque: string(encoded),
|
|
Revision: digestStrings("native-session-locator", string(encoded)),
|
|
ProjectID: project.ProjectID,
|
|
WorkspaceID: project.WorkspaceID,
|
|
WorkUnitID: work.Unit.ID,
|
|
AttemptID: work.AttemptID,
|
|
}
|
|
locator = &record
|
|
}
|
|
args = append(args, prompt)
|
|
environment, err := providerEnvironment(
|
|
resolved,
|
|
binding,
|
|
workingDirectory,
|
|
)
|
|
if err != nil {
|
|
return agenttask.ConfinementCommand{}, nil, err
|
|
}
|
|
return agenttask.ConfinementCommand{
|
|
Name: resolved.Provider.Command,
|
|
Args: args,
|
|
Env: environment,
|
|
}, locator, nil
|
|
}
|
|
|
|
func prepareResumeCatalogCommand(
|
|
resolved agentconfig.ResolvedProfile,
|
|
target agenttask.ExecutionTarget,
|
|
binding agenttask.ConfinementBinding,
|
|
workingDirectory, prompt, nativeSessionFile string,
|
|
reference nativeSessionReference,
|
|
) (agenttask.ConfinementCommand, error) {
|
|
if err := validateCatalogTarget(resolved, target); err != nil {
|
|
return agenttask.ConfinementCommand{}, err
|
|
}
|
|
if !strings.EqualFold(resolved.Provider.ID, "pi") {
|
|
return agenttask.ConfinementCommand{}, errors.New(
|
|
"taskloop: native session resume is supported only for Pi",
|
|
)
|
|
}
|
|
if err := validateNativeSessionReference(reference, resolved, target, binding); err != nil {
|
|
return agenttask.ConfinementCommand{}, err
|
|
}
|
|
if err := validateContainedRegularFile(
|
|
reference.SessionDirectory,
|
|
nativeSessionFile,
|
|
); err != nil {
|
|
return agenttask.ConfinementCommand{}, err
|
|
}
|
|
baseArgs := resolved.Profile.ResumeArgs
|
|
if len(baseArgs) == 0 {
|
|
baseArgs = resolved.Profile.Args
|
|
}
|
|
args := expandModelTarget(baseArgs, resolved.Model.Target)
|
|
args = append(args,
|
|
"--session", nativeSessionFile,
|
|
"--session-dir", reference.SessionDirectory,
|
|
prompt,
|
|
)
|
|
environment, err := providerEnvironment(
|
|
resolved,
|
|
binding,
|
|
workingDirectory,
|
|
)
|
|
if err != nil {
|
|
return agenttask.ConfinementCommand{}, err
|
|
}
|
|
return agenttask.ConfinementCommand{
|
|
Name: resolved.Provider.Command,
|
|
Args: args,
|
|
Env: environment,
|
|
}, nil
|
|
}
|
|
|
|
func validateCatalogTarget(
|
|
resolved agentconfig.ResolvedProfile,
|
|
target agenttask.ExecutionTarget,
|
|
) error {
|
|
if resolved.Provider.ID != target.ProviderID ||
|
|
resolved.Model.ID != target.ModelID ||
|
|
resolved.Profile.ID != target.ProfileID ||
|
|
guardProfile(resolved).Revision != target.ProfileRevision {
|
|
return errors.New("taskloop: selected provider identity does not match the catalog")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func providerEnvironment(
|
|
resolved agentconfig.ResolvedProfile,
|
|
binding agenttask.ConfinementBinding,
|
|
workingDirectory string,
|
|
) ([]string, error) {
|
|
environment := append([]string(nil), os.Environ()...)
|
|
environment = append(environment, resolved.Profile.Env...)
|
|
if workingDirectory != "" {
|
|
environment = replaceEnvironment(environment, "PWD", workingDirectory)
|
|
}
|
|
if binding.TaskRoot != "" {
|
|
environment = replaceEnvironment(
|
|
environment,
|
|
"IOP_AGENT_TASK_ROOT",
|
|
binding.TaskRoot,
|
|
)
|
|
}
|
|
if len(binding.WritableRoots) == 3 {
|
|
tempRoot := binding.WritableRoots[1]
|
|
for _, key := range []string{"TMPDIR", "TMP", "TEMP"} {
|
|
environment = replaceEnvironment(environment, key, tempRoot)
|
|
}
|
|
}
|
|
if !strings.EqualFold(resolved.Provider.ID, "codex") {
|
|
return environment, nil
|
|
}
|
|
if len(binding.WritableRoots) != 3 {
|
|
return nil, errors.New("taskloop: codex confinement has invalid writable roots")
|
|
}
|
|
codexHome, err := prepareCodexRuntimeHome(binding.WritableRoots[2])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
environment = replaceEnvironment(environment, "CODEX_HOME", codexHome)
|
|
if runtime.GOOS == "darwin" {
|
|
const systemCABundle = "/etc/ssl/cert.pem"
|
|
if info, statErr := os.Stat(systemCABundle); statErr == nil && info.Mode().IsRegular() {
|
|
environment = replaceEnvironment(
|
|
environment,
|
|
"SSL_CERT_FILE",
|
|
systemCABundle,
|
|
)
|
|
}
|
|
}
|
|
return environment, nil
|
|
}
|
|
|
|
func prepareCodexRuntimeHome(cacheRoot string) (string, error) {
|
|
if cacheRoot == "" || !filepath.IsAbs(cacheRoot) {
|
|
return "", errors.New("taskloop: codex runtime cache root is invalid")
|
|
}
|
|
codexHome := filepath.Join(cacheRoot, "codex-home")
|
|
if err := validateContainedPath(cacheRoot, codexHome); err != nil ||
|
|
codexHome == cacheRoot {
|
|
return "", errors.New("taskloop: codex runtime home escapes the cache root")
|
|
}
|
|
if err := os.MkdirAll(codexHome, 0o700); err != nil {
|
|
return "", fmt.Errorf("taskloop: create codex runtime home: %w", err)
|
|
}
|
|
if err := os.Chmod(codexHome, 0o700); err != nil {
|
|
return "", fmt.Errorf("taskloop: protect codex runtime home: %w", err)
|
|
}
|
|
sourceHome := strings.TrimSpace(os.Getenv("CODEX_HOME"))
|
|
if sourceHome == "" {
|
|
userHome, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("taskloop: resolve codex authentication home: %w", err)
|
|
}
|
|
sourceHome = filepath.Join(userHome, ".codex")
|
|
}
|
|
sourceHome, err := filepath.Abs(sourceHome)
|
|
if err != nil {
|
|
return "", fmt.Errorf("taskloop: resolve codex authentication home: %w", err)
|
|
}
|
|
for _, name := range []string{"auth.json", "config.toml"} {
|
|
source := filepath.Join(sourceHome, name)
|
|
info, statErr := os.Stat(source)
|
|
if statErr != nil {
|
|
if name == "config.toml" && os.IsNotExist(statErr) {
|
|
continue
|
|
}
|
|
return "", fmt.Errorf(
|
|
"taskloop: inspect codex authentication file %s: %w",
|
|
name,
|
|
statErr,
|
|
)
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return "", fmt.Errorf(
|
|
"taskloop: codex authentication file %s is not regular",
|
|
name,
|
|
)
|
|
}
|
|
destination := filepath.Join(codexHome, name)
|
|
if existing, linkErr := os.Readlink(destination); linkErr == nil {
|
|
if existing != source {
|
|
return "", fmt.Errorf(
|
|
"taskloop: codex runtime link %s changed identity",
|
|
name,
|
|
)
|
|
}
|
|
continue
|
|
} else if !os.IsNotExist(linkErr) {
|
|
return "", fmt.Errorf(
|
|
"taskloop: inspect codex runtime link %s: %w",
|
|
name,
|
|
linkErr,
|
|
)
|
|
}
|
|
if err := os.Symlink(source, destination); err != nil {
|
|
return "", fmt.Errorf(
|
|
"taskloop: project codex runtime link %s: %w",
|
|
name,
|
|
err,
|
|
)
|
|
}
|
|
}
|
|
return codexHome, nil
|
|
}
|
|
|
|
func newNativeSessionReference(
|
|
resolved agentconfig.ResolvedProfile,
|
|
target agenttask.ExecutionTarget,
|
|
binding agenttask.ConfinementBinding,
|
|
project agenttask.ProjectRecord,
|
|
work agenttask.WorkRecord,
|
|
purpose string,
|
|
) (nativeSessionReference, error) {
|
|
if len(binding.WritableRoots) == 0 {
|
|
return nativeSessionReference{}, errors.New(
|
|
"taskloop: Pi native session requires a confined writable cache root",
|
|
)
|
|
}
|
|
cacheRoot := binding.WritableRoots[len(binding.WritableRoots)-1]
|
|
if !filepath.IsAbs(cacheRoot) || filepath.Clean(cacheRoot) != cacheRoot {
|
|
return nativeSessionReference{}, errors.New(
|
|
"taskloop: Pi native session cache root is invalid",
|
|
)
|
|
}
|
|
sessionID := deterministicSessionID(
|
|
string(project.ProjectID),
|
|
string(project.WorkspaceID),
|
|
string(work.Unit.ID),
|
|
string(work.AttemptID),
|
|
target.ProfileRevision,
|
|
binding.Revision,
|
|
purpose,
|
|
)
|
|
return nativeSessionReference{
|
|
SchemaVersion: nativeSessionLocatorSchemaVersion,
|
|
ProviderID: resolved.Provider.ID,
|
|
ModelID: resolved.Model.ID,
|
|
ProfileID: resolved.Profile.ID,
|
|
ProfileRevision: target.ProfileRevision,
|
|
SessionID: sessionID,
|
|
SessionDirectory: filepath.Join(cacheRoot, "pi-sessions", sessionID),
|
|
ConfinementRevision: binding.Revision,
|
|
IsolationID: binding.IsolationID,
|
|
IsolationRevision: binding.IsolationRevision,
|
|
}, nil
|
|
}
|
|
|
|
func deterministicSessionID(parts ...string) string {
|
|
sum := sha256.Sum256([]byte(strings.Join(parts, "\x00")))
|
|
sum[6] = (sum[6] & 0x0f) | 0x40
|
|
sum[8] = (sum[8] & 0x3f) | 0x80
|
|
return fmt.Sprintf(
|
|
"%x-%x-%x-%x-%x",
|
|
sum[0:4],
|
|
sum[4:6],
|
|
sum[6:8],
|
|
sum[8:10],
|
|
sum[10:16],
|
|
)
|
|
}
|
|
|
|
func validateNativeSessionReference(
|
|
reference nativeSessionReference,
|
|
resolved agentconfig.ResolvedProfile,
|
|
target agenttask.ExecutionTarget,
|
|
binding agenttask.ConfinementBinding,
|
|
) error {
|
|
if reference.SchemaVersion != nativeSessionLocatorSchemaVersion ||
|
|
reference.ProviderID != resolved.Provider.ID ||
|
|
reference.ModelID != resolved.Model.ID ||
|
|
reference.ProfileID != resolved.Profile.ID ||
|
|
reference.ProfileRevision != target.ProfileRevision ||
|
|
reference.ConfinementRevision != binding.Revision ||
|
|
reference.IsolationID != binding.IsolationID ||
|
|
reference.IsolationRevision != binding.IsolationRevision ||
|
|
reference.SessionID == "" {
|
|
return errors.New("taskloop: native session locator identity mismatch")
|
|
}
|
|
if len(binding.WritableRoots) == 0 {
|
|
return errors.New("taskloop: native session confinement has no writable roots")
|
|
}
|
|
cacheRoot := binding.WritableRoots[len(binding.WritableRoots)-1]
|
|
if err := validateContainedPath(cacheRoot, reference.SessionDirectory); err != nil {
|
|
return fmt.Errorf("taskloop: native session directory: %w", err)
|
|
}
|
|
if filepath.Base(reference.SessionDirectory) != reference.SessionID {
|
|
return errors.New("taskloop: native session directory identity mismatch")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolveNativeSessionFile(reference nativeSessionReference) (string, error) {
|
|
matches, err := filepath.Glob(
|
|
filepath.Join(reference.SessionDirectory, "*"+reference.SessionID+"*.jsonl"),
|
|
)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(matches) != 1 {
|
|
return "", errors.New(
|
|
"taskloop: Pi repair requires exactly one matching native session file",
|
|
)
|
|
}
|
|
if err := validateContainedRegularFile(reference.SessionDirectory, matches[0]); err != nil {
|
|
return "", err
|
|
}
|
|
return matches[0], nil
|
|
}
|
|
|
|
func validateContainedPath(root, path string) error {
|
|
if !filepath.IsAbs(root) || !filepath.IsAbs(path) ||
|
|
filepath.Clean(root) != root || filepath.Clean(path) != path {
|
|
return errors.New("path must be absolute and clean")
|
|
}
|
|
relative, err := filepath.Rel(root, path)
|
|
if err != nil || relative == ".." ||
|
|
strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
|
return errors.New("path escapes its owned root")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateContainedRegularFile(root, path string) error {
|
|
if err := validateContainedPath(root, path); err != nil {
|
|
return err
|
|
}
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.Mode().IsRegular() || info.Size() <= 0 ||
|
|
info.Size() > maxWorkflowArtifactBytes {
|
|
return errors.New("taskloop: native session is not one bounded regular file")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func invokeConfined(
|
|
ctx context.Context,
|
|
confinement agenttask.InvocationConfinement,
|
|
command agenttask.ConfinementCommand,
|
|
) error {
|
|
if confinement == nil {
|
|
return errors.New("taskloop: executable confinement is unavailable")
|
|
}
|
|
binding := confinement.Binding()
|
|
if confinement.Revision() != binding.Revision {
|
|
return errors.New("taskloop: executable confinement revision mismatch")
|
|
}
|
|
if err := confinement.Validate(binding); err != nil {
|
|
return fmt.Errorf("taskloop: validate executable confinement: %w", err)
|
|
}
|
|
started, err := confinement.Start(ctx, command)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if started == nil || started.Child() == nil || started.Child().Process == nil ||
|
|
started.Stdin() == nil || started.Stdout() == nil || started.Stderr() == nil {
|
|
if started != nil {
|
|
_ = started.Abort()
|
|
}
|
|
return errors.New("taskloop: confined provider child is incomplete")
|
|
}
|
|
_ = started.Stdin().Close()
|
|
var stdout, stderr boundedBuffer
|
|
var drains sync.WaitGroup
|
|
drains.Add(2)
|
|
go func() {
|
|
defer drains.Done()
|
|
_, _ = io.Copy(&stdout, started.Stdout())
|
|
_ = started.Stdout().Close()
|
|
}()
|
|
go func() {
|
|
defer drains.Done()
|
|
_, _ = io.Copy(&stderr, started.Stderr())
|
|
_ = started.Stderr().Close()
|
|
}()
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
waitErr := started.Child().Wait()
|
|
drains.Wait()
|
|
done <- waitErr
|
|
}()
|
|
select {
|
|
case <-ctx.Done():
|
|
if signalErr := started.Child().Process.Signal(syscall.SIGTERM); signalErr != nil &&
|
|
!errors.Is(signalErr, os.ErrProcessDone) {
|
|
_ = started.Child().Process.Kill()
|
|
}
|
|
<-done
|
|
return ctx.Err()
|
|
case waitErr := <-done:
|
|
if waitErr != nil {
|
|
return errors.New("taskloop: confined provider exited unsuccessfully")
|
|
}
|
|
return nil
|
|
}
|
|
}
|