Skip to content

Commit abee160

Browse files
committed
fix(tasks): stabilize lifecycle logs and operational log.scan root scope
1 parent 2705e7f commit abee160

8 files changed

Lines changed: 460 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ Formatting rules:
1515

1616
## [Unreleased]
1717

18+
### Fixed
19+
20+
- Task lifecycle log shipping now truncates oversized log lines to the API-safe limit and retries queued-state conflicts before failing, reducing cases where tasks appear stuck in `queued` without visible progress.
21+
- Root `log.scan` execution now normalizes legacy `task` scope requests to `operational` scope and uses a dedicated operational helper path, so log scan operations no longer depend on task-root grants.
22+
1823
## [1.0.6] - 2026-04-05
1924

2025
### Added

internal/agentctl/commands.go

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,11 @@ const (
3333
linuxPrivilegedUpdateHelperPath = "/usr/local/libexec/noderax-agent-self-update"
3434
linuxRootProfileHelperPath = "/usr/local/libexec/noderax-agent-root-profile"
3535
linuxPackageMutationHelperPath = "/usr/local/libexec/noderax-agent-package-mutation"
36+
linuxOperationalLogScanHelperPath = "/usr/local/libexec/noderax-agent-operational-log-scan"
3637
linuxTaskRootHelperPath = "/usr/local/libexec/noderax-agent-task-root"
3738
linuxPrivilegedUpdateRequestPath = linuxServiceHome + "/update-request.json"
3839
linuxPackageMutationRequestPath = linuxServiceHome + "/package-mutation-request.txt"
40+
linuxOperationalLogScanRequestPath = linuxServiceHome + "/operational-log-scan-request.json"
3941
linuxTaskRootRequestPath = linuxServiceHome + "/task-root-request.txt"
4042
linuxConfigPath = "/etc/noderax-agent/config.json"
4143
linuxStatePath = "/var/lib/noderax-agent/agent_identity.json"
@@ -63,6 +65,7 @@ type platformSpec struct {
6365
PrivilegedUpdateHelperPath string
6466
RootProfileHelperPath string
6567
PackageMutationHelperPath string
68+
OperationalLogScanHelperPath string
6669
TaskRootHelperPath string
6770
BaseSudoersPath string
6871
RootAccessSudoersPath string
@@ -247,6 +250,9 @@ func (c CLI) Install(ctx context.Context, args []string) error {
247250
if err := writePackageMutationHelper(spec); err != nil {
248251
return fmt.Errorf("write package mutation helper: %w", err)
249252
}
253+
if err := writeOperationalLogScanHelper(spec); err != nil {
254+
return fmt.Errorf("write operational log scan helper: %w", err)
255+
}
250256
if err := writeTaskRootHelper(spec); err != nil {
251257
return fmt.Errorf("write task root helper: %w", err)
252258
}
@@ -426,6 +432,18 @@ func (c CLI) Uninstall(ctx context.Context) error {
426432
packageMutationHelperRemoved,
427433
)
428434

435+
operationalLogScanHelperRemoved, err := removeFileIfExists(spec.OperationalLogScanHelperPath)
436+
if err != nil {
437+
return err
438+
}
439+
recordRemovalResult(
440+
&removed,
441+
&missing,
442+
"operational log scan helper",
443+
spec.OperationalLogScanHelperPath,
444+
operationalLogScanHelperRemoved,
445+
)
446+
429447
taskRootHelperRemoved, err := removeFileIfExists(spec.TaskRootHelperPath)
430448
if err != nil {
431449
return err
@@ -615,6 +633,7 @@ func currentPlatformSpec() (platformSpec, error) {
615633
PrivilegedUpdateHelperPath: linuxPrivilegedUpdateHelperPath,
616634
RootProfileHelperPath: linuxRootProfileHelperPath,
617635
PackageMutationHelperPath: linuxPackageMutationHelperPath,
636+
OperationalLogScanHelperPath: linuxOperationalLogScanHelperPath,
618637
TaskRootHelperPath: linuxTaskRootHelperPath,
619638
BaseSudoersPath: linuxBaseSudoersPath,
620639
RootAccessSudoersPath: linuxRootAccessSudoersPath,
@@ -638,6 +657,7 @@ func currentPlatformSpec() (platformSpec, error) {
638657
PrivilegedUpdateHelperPath: "",
639658
RootProfileHelperPath: "",
640659
PackageMutationHelperPath: "",
660+
OperationalLogScanHelperPath: "",
641661
TaskRootHelperPath: "",
642662
BaseSudoersPath: "",
643663
RootAccessSudoersPath: "",
@@ -1223,6 +1243,24 @@ func writePackageMutationHelper(spec platformSpec) error {
12231243
return nil
12241244
}
12251245

1246+
func writeOperationalLogScanHelper(spec platformSpec) error {
1247+
path := strings.TrimSpace(spec.OperationalLogScanHelperPath)
1248+
if path == "" {
1249+
return nil
1250+
}
1251+
1252+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
1253+
return fmt.Errorf("create helper directory for %s: %w", path, err)
1254+
}
1255+
1256+
content := renderOperationalLogScanHelper(spec)
1257+
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
1258+
return fmt.Errorf("write helper %s: %w", path, err)
1259+
}
1260+
1261+
return nil
1262+
}
1263+
12261264
func writeTaskRootHelper(spec platformSpec) error {
12271265
path := strings.TrimSpace(spec.TaskRootHelperPath)
12281266
if path == "" {
@@ -1414,6 +1452,28 @@ esac
14141452
`, spec.PackageMutationHelperPath, linuxPackageMutationRequestPath)
14151453
}
14161454

1455+
func renderOperationalLogScanHelper(spec platformSpec) string {
1456+
return fmt.Sprintf(`#!/bin/sh
1457+
set -eu
1458+
1459+
HELPER_PATH=%q
1460+
REQUEST_FILE=%q
1461+
AGENT_BINARY=%q
1462+
1463+
if [ "$#" -ne 0 ]; then
1464+
echo "usage: ${HELPER_PATH}" >&2
1465+
exit 64
1466+
fi
1467+
1468+
if [ ! -f "${REQUEST_FILE}" ]; then
1469+
echo "operational log scan request file is missing" >&2
1470+
exit 1
1471+
fi
1472+
1473+
exec "${AGENT_BINARY}" log-scan --request "${REQUEST_FILE}"
1474+
`, spec.OperationalLogScanHelperPath, linuxOperationalLogScanRequestPath, spec.BinaryPath)
1475+
}
1476+
14171477
func renderTaskRootHelper(spec platformSpec) string {
14181478
return fmt.Sprintf(`#!/bin/sh
14191479
set -eu
@@ -1483,6 +1543,7 @@ SERVICE_USER=%q
14831543
SERVICE_NAME=%q
14841544
SUDOERS_FILE=%q
14851545
PACKAGE_MUTATION_HELPER=%q
1546+
OPERATIONAL_LOG_SCAN_HELPER=%q
14861547
TASK_ROOT_HELPER=%q
14871548
14881549
if [ "$#" -ne 2 ] || [ "$1" != "apply" ]; then
@@ -1529,6 +1590,11 @@ append_operational_profile() {
15291590
append_alias "NODERAX_AGENT_PACKAGE_MUTATIONS"
15301591
fi
15311592
1593+
if [ -x "${OPERATIONAL_LOG_SCAN_HELPER}" ]; then
1594+
append_line "Cmnd_Alias NODERAX_AGENT_OPERATIONAL_LOG_SCAN = ${OPERATIONAL_LOG_SCAN_HELPER}"
1595+
append_alias "NODERAX_AGENT_OPERATIONAL_LOG_SCAN"
1596+
fi
1597+
15321598
if [ -n "${SYSTEMCTL_PATH}" ]; then
15331599
append_line "Cmnd_Alias NODERAX_AGENT_SERVICE_CONTROL = ${SYSTEMCTL_PATH} restart ${SERVICE_NAME}, ${SYSTEMCTL_PATH} restart ${SERVICE_NAME%%.service}"
15341600
append_alias "NODERAX_AGENT_SERVICE_CONTROL"
@@ -1628,7 +1694,7 @@ if command -v visudo >/dev/null 2>&1; then
16281694
fi
16291695
16301696
install -o root -g root -m 0440 "${TMP_FILE}" "${SUDOERS_FILE}"
1631-
`, spec.RootProfileHelperPath, spec.ServiceUser, spec.ServiceName, spec.RootAccessSudoersPath, spec.PackageMutationHelperPath, spec.TaskRootHelperPath)
1697+
`, spec.RootProfileHelperPath, spec.ServiceUser, spec.ServiceName, spec.RootAccessSudoersPath, spec.PackageMutationHelperPath, spec.OperationalLogScanHelperPath, spec.TaskRootHelperPath)
16321698
}
16331699

16341700
func writeServiceUnit(path, content string) error {

internal/agentctl/commands_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ func TestRenderRootProfileHelperSupportsCombinedProfiles(t *testing.T) {
135135
ServiceName: "noderax-agent.service",
136136
RootAccessSudoersPath: "/etc/sudoers.d/noderax-agent-root-access",
137137
PackageMutationHelperPath: "/usr/local/libexec/noderax-agent-package-mutation",
138+
OperationalLogScanHelperPath: "/usr/local/libexec/noderax-agent-operational-log-scan",
138139
TaskRootHelperPath: "/usr/local/libexec/noderax-agent-task-root",
139140
}
140141

@@ -146,6 +147,8 @@ func TestRenderRootProfileHelperSupportsCombinedProfiles(t *testing.T) {
146147
"operational_terminal)",
147148
"task_terminal)",
148149
"append_operational_profile",
150+
"NODERAX_AGENT_OPERATIONAL_LOG_SCAN",
151+
"/usr/local/libexec/noderax-agent-operational-log-scan",
149152
"append_task_profile",
150153
"append_terminal_profile",
151154
"for shell_name in bash zsh sh; do",

internal/agentctl/update.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,9 @@ func (c CLI) applyManagedUpdate(
322322
if err := writePackageMutationHelper(spec); err != nil {
323323
return fmt.Errorf("refresh package mutation helper: %w", err)
324324
}
325+
if err := writeOperationalLogScanHelper(spec); err != nil {
326+
return fmt.Errorf("refresh operational log scan helper: %w", err)
327+
}
325328
if err := writeTaskRootHelper(spec); err != nil {
326329
return fmt.Errorf("refresh task root helper: %w", err)
327330
}

internal/tasks/executor.go

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ const (
3333
linuxPrivilegedUpdateRequestPath = "/var/lib/noderax-agent/update-request.json"
3434
linuxPackageMutationHelperPath = "/usr/local/libexec/noderax-agent-package-mutation"
3535
linuxPackageMutationRequestPath = "/var/lib/noderax-agent/package-mutation-request.txt"
36+
linuxOperationalLogScanHelperPath = "/usr/local/libexec/noderax-agent-operational-log-scan"
37+
linuxOperationalLogScanRequestPath = "/var/lib/noderax-agent/operational-log-scan-request.json"
3638
linuxTaskRootHelperPath = "/usr/local/libexec/noderax-agent-task-root"
3739
linuxTaskRootRequestPath = "/var/lib/noderax-agent/task-root-request.txt"
3840
linuxAgentServiceName = "noderax-agent.service"
@@ -149,6 +151,7 @@ type ShellExecutor struct {
149151
fileExists func(string) bool
150152
privilegedUpdateRequestPath string
151153
packageMutationRequestPath string
154+
operationalLogScanRequestPath string
152155
taskRootRequestPath string
153156
newCommand func(context.Context, string, ...string) commandRunner
154157
rootScopeChecker func(string) bool
@@ -166,6 +169,7 @@ func NewShellExecutor(defaultTimeout time.Duration) *ShellExecutor {
166169
},
167170
privilegedUpdateRequestPath: linuxPrivilegedUpdateRequestPath,
168171
packageMutationRequestPath: linuxPackageMutationRequestPath,
172+
operationalLogScanRequestPath: linuxOperationalLogScanRequestPath,
169173
taskRootRequestPath: linuxTaskRootRequestPath,
170174
newCommand: newExecCommandRunner,
171175
}
@@ -453,22 +457,22 @@ func (e *ShellExecutor) logScanCommand(payload json.RawMessage) (commandSpec, er
453457
commandArgs := []string{"log-scan", "--request", requestPath}
454458

455459
if parsed.RunAsRoot {
456-
if strings.TrimSpace(parsed.RootScope) == "" {
457-
return commandSpec{}, fmt.Errorf("%w: log.scan root execution requires rootScope", ErrInvalidTaskPayload)
460+
effectiveScope := strings.TrimSpace(parsed.RootScope)
461+
if effectiveScope == "" || effectiveScope == "task" {
462+
effectiveScope = "operational"
458463
}
459-
if parsed.RootScope != "task" {
460-
return commandSpec{}, fmt.Errorf("%w: log.scan root execution requires task scope", ErrInvalidTaskPayload)
464+
if effectiveScope != "operational" {
465+
return commandSpec{}, fmt.Errorf("%w: log.scan root execution requires operational scope", ErrInvalidTaskPayload)
461466
}
462-
if e.rootScopeChecker != nil && !e.rootScopeChecker(parsed.RootScope) {
463-
return commandSpec{}, fmt.Errorf("%w: current root access profile does not allow %s scope", ErrUnsupportedExecutionEnvironment, parsed.RootScope)
467+
if e.rootScopeChecker != nil && !e.rootScopeChecker(effectiveScope) {
468+
return commandSpec{}, fmt.Errorf("%w: current root access profile does not allow %s scope", ErrUnsupportedExecutionEnvironment, effectiveScope)
464469
}
465470

466-
if e.goos == "linux" && e.fileExists(linuxTaskRootHelperPath) {
467-
rootCommand := formatCommandForLog(commandName, commandArgs)
468-
if err := writeTaskRootRequest(e.taskRootRequestPath, rootCommand); err != nil {
469-
return commandSpec{}, fmt.Errorf("%w: write task root request: %v", ErrUnsupportedExecutionEnvironment, err)
471+
if e.goos == "linux" && e.fileExists(linuxOperationalLogScanHelperPath) {
472+
if err := writeOperationalLogScanRequest(e.operationalLogScanRequestPath, payload); err != nil {
473+
return commandSpec{}, fmt.Errorf("%w: write operational log scan request: %v", ErrUnsupportedExecutionEnvironment, err)
470474
}
471-
commandName, commandArgs, err = e.wrapWithSudo(linuxTaskRootHelperPath, nil)
475+
commandName, commandArgs, err = e.wrapWithSudo(linuxOperationalLogScanHelperPath, nil)
472476
} else {
473477
commandName, commandArgs, err = e.wrapWithSudo(commandName, commandArgs)
474478
}
@@ -968,6 +972,45 @@ func writeTaskRootRequest(path string, command string) error {
968972
return nil
969973
}
970974

975+
func writeOperationalLogScanRequest(path string, payload json.RawMessage) error {
976+
cleanPath := filepath.Clean(strings.TrimSpace(path))
977+
if cleanPath == "" {
978+
return fmt.Errorf("request path is empty")
979+
}
980+
981+
if err := os.MkdirAll(filepath.Dir(cleanPath), 0o755); err != nil {
982+
return fmt.Errorf("create operational log scan request directory: %w", err)
983+
}
984+
985+
file, err := os.CreateTemp(filepath.Dir(cleanPath), ".noderax-agent-operational-log-scan-*.json")
986+
if err != nil {
987+
return fmt.Errorf("create operational log scan request file: %w", err)
988+
}
989+
990+
tempPath := file.Name()
991+
if _, err := file.Write(payload); err != nil {
992+
file.Close()
993+
_ = os.Remove(tempPath)
994+
return fmt.Errorf("write operational log scan request file: %w", err)
995+
}
996+
if err := file.Chmod(0o600); err != nil {
997+
file.Close()
998+
_ = os.Remove(tempPath)
999+
return fmt.Errorf("chmod operational log scan request file: %w", err)
1000+
}
1001+
if err := file.Close(); err != nil {
1002+
_ = os.Remove(tempPath)
1003+
return fmt.Errorf("close operational log scan request file: %w", err)
1004+
}
1005+
1006+
if err := os.Rename(tempPath, cleanPath); err != nil {
1007+
_ = os.Remove(tempPath)
1008+
return fmt.Errorf("replace operational log scan request file: %w", err)
1009+
}
1010+
1011+
return nil
1012+
}
1013+
9711014
func writeManagedUpdateRequest(path string, payload agentUpdatePayload) error {
9721015
cleanPath := filepath.Clean(strings.TrimSpace(path))
9731016
if cleanPath == "" {

internal/tasks/executor_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"io"
99
"os"
1010
"os/exec"
11+
"path/filepath"
1112
"reflect"
1213
"strconv"
1314
"strings"
@@ -474,6 +475,99 @@ func TestShellExecutorExecutePropagatesExitCodeAndLogs(t *testing.T) {
474475
}
475476
}
476477

478+
func TestShellExecutorLogScanRootUsesOperationalScopeAndHelper(t *testing.T) {
479+
t.Parallel()
480+
481+
executor := NewShellExecutor(5 * time.Minute)
482+
executor.goos = "linux"
483+
executor.lookPath = fakeLookPath(map[string]string{
484+
"noderax-agent": "/usr/local/bin/noderax-agent",
485+
"sudo": "/usr/bin/sudo",
486+
})
487+
488+
requestPath := filepath.Join(t.TempDir(), "operational-log-scan-request.json")
489+
executor.operationalLogScanRequestPath = requestPath
490+
executor.fileExists = func(path string) bool {
491+
return path == linuxOperationalLogScanHelperPath
492+
}
493+
494+
checkedScope := ""
495+
executor.SetRootScopeChecker(func(scope string) bool {
496+
checkedScope = scope
497+
return scope == "operational"
498+
})
499+
500+
recorder := &recordingCommandFactory{
501+
runner: &fakeCommandRunner{stdoutText: "{}\n"},
502+
}
503+
executor.newCommand = recorder.factory
504+
505+
_, err := executor.Execute(context.Background(), api.Task{
506+
Type: TaskTypeLogScan,
507+
Payload: mustJSON(t, map[string]any{
508+
"mode": "preview",
509+
"sourcePresetId": "auth.log",
510+
"runAsRoot": true,
511+
"rootScope": "task",
512+
}),
513+
}, nil)
514+
if err != nil {
515+
t.Fatalf("Execute returned error: %v", err)
516+
}
517+
518+
if checkedScope != "operational" {
519+
t.Fatalf("expected root scope checker to receive operational, got %q", checkedScope)
520+
}
521+
522+
if recorder.name != "/usr/bin/sudo" {
523+
t.Fatalf("command name mismatch: got %q want %q", recorder.name, "/usr/bin/sudo")
524+
}
525+
526+
wantArgs := []string{"-n", linuxOperationalLogScanHelperPath}
527+
if !reflect.DeepEqual(recorder.args, wantArgs) {
528+
t.Fatalf("command args mismatch: got %v want %v", recorder.args, wantArgs)
529+
}
530+
531+
requestBytes, err := os.ReadFile(requestPath)
532+
if err != nil {
533+
t.Fatalf("read operational request file: %v", err)
534+
}
535+
if !strings.Contains(string(requestBytes), `"sourcePresetId":"auth.log"`) {
536+
t.Fatalf("unexpected request payload: %s", string(requestBytes))
537+
}
538+
}
539+
540+
func TestShellExecutorLogScanRootRejectsNonOperationalScope(t *testing.T) {
541+
t.Parallel()
542+
543+
executor := NewShellExecutor(5 * time.Minute)
544+
executor.goos = "linux"
545+
executor.lookPath = fakeLookPath(map[string]string{
546+
"noderax-agent": "/usr/local/bin/noderax-agent",
547+
"sudo": "/usr/bin/sudo",
548+
})
549+
550+
recorder := &recordingCommandFactory{runner: &fakeCommandRunner{}}
551+
executor.newCommand = recorder.factory
552+
553+
_, err := executor.Execute(context.Background(), api.Task{
554+
Type: TaskTypeLogScan,
555+
Payload: mustJSON(t, map[string]any{
556+
"mode": "preview",
557+
"sourcePresetId": "auth.log",
558+
"runAsRoot": true,
559+
"rootScope": "terminal",
560+
}),
561+
}, nil)
562+
563+
if !errors.Is(err, ErrInvalidTaskPayload) {
564+
t.Fatalf("expected ErrInvalidTaskPayload, got %v", err)
565+
}
566+
if recorder.calls != 0 {
567+
t.Fatalf("expected no command execution, got %d calls", recorder.calls)
568+
}
569+
}
570+
477571
func TestCommandHelperProcess(t *testing.T) {
478572
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
479573
return

0 commit comments

Comments
 (0)