detached 9c50523

feat(executor): print server-side token permissions (#26)

commit
9c50523b2cfc843e1f78eb02c98252b727b0ccaf
author
brice · · 2026-10-14 23:30 UTC
committer
bnema · · 2026-10-14 23:30 UTC
parent
c936897
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
9 files · +414 -103
M internal/execstream/exec.proto +12 -0
@@ -107,6 +107,18 @@ message JobSpec {
107107 // secrets are not available. Never set for servers without the feature, for
108108 // which an absent Job.trusted only means "not sent".
109109 bool secrets_withheld = 19;
110+ // token_permissions are the effective permissions of the job's GITHUB_TOKEN
111+ // as the server sent them (Job.permissions, feature ci.secrets): "Set up job"
112+ // prints them instead of working them out from the workflow. Unset when the
113+ // server sent none (an older server): the executor then reads them from the
114+ // workflow.
115+ TokenPermissions token_permissions = 20;
116+}
117+
118+// TokenPermissions maps a permission key ("contents", "pull-requests", ...) to
119+// "read", "write" or "none"; a key that is absent is none.
120+message TokenPermissions {
121+ map<string, string> levels = 1;
110122 }
111123
112124 enum StreamKind {
@@ -107,6 +107,18 @@ message JobSpec {
107 // secrets are not available. Never set for servers without the feature, for107 // secrets are not available. Never set for servers without the feature, for
108 // which an absent Job.trusted only means "not sent".108 // which an absent Job.trusted only means "not sent".
109 bool secrets_withheld = 19;109 bool secrets_withheld = 19;
110+ // token_permissions are the effective permissions of the job's GITHUB_TOKEN
111+ // as the server sent them (Job.permissions, feature ci.secrets): "Set up job"
112+ // prints them instead of working them out from the workflow. Unset when the
113+ // server sent none (an older server): the executor then reads them from the
114+ // workflow.
115+ TokenPermissions token_permissions = 20;
116+}
117+
118+// TokenPermissions maps a permission key ("contents", "pull-requests", ...) to
119+// "read", "write" or "none"; a key that is absent is none.
120+message TokenPermissions {
121+ map<string, string> levels = 1;
110 }122 }
111 123
112 enum StreamKind {124 enum StreamKind {
M internal/execstream/execpb/exec.pb.go +164 -95 generated file: collapsed
M internal/executor/job.go +18 -5
@@ -85,6 +85,11 @@ type Config struct {
8585 // SecretsWithheld says that the server did not send secrets because the run
8686 // is not trusted: "Set up job" says so.
8787 SecretsWithheld bool
88+ // TokenPermissions are the permissions of GITHUB_TOKEN as the server sent
89+ // them (Job.permissions: key to "read", "write" or "none"), which "Set up
90+ // job" prints as they are. Nil means the server sent none: they are then
91+ // worked out from the workflow. An empty, non-nil map is "none at all".
92+ TokenPermissions map[string]string
8893 // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG).
8994 Debug bool
9095 // Actions configures the actions of the job (resolution, downloads, Node.js,
@@ -729,13 +734,21 @@ const SecretsWithheldLine = "Secrets are not available to this job: the run was
729734 // and the cache mode.
730735 //
731736 // GitHub's runner prints what the server sends as
732-// system.github.token.permissions (JobExtension.cs:154-166); ermite's Job
733-// message does not carry it, so the group is worked out from the workflow
734-// with the rules recorded on github.com (tokenPermissionLines). It is always
735-// printed: Metadata is listed even for "permissions: {}".
737+// system.github.token.permissions (JobExtension.cs:154-166). ermite's server
738+// sends them in Job.permissions, already merged and lowered by its trust rule:
739+// they are printed as they are (serverPermissionLines). From a server that
740+// sends none the group is worked out from the workflow with the rules recorded
741+// on github.com (tokenPermissionLines). It is always printed: Metadata is listed
742+// even for "permissions: {}".
736743 func (j *job) printTokenInfo() {
737744 j.out(1, "##[group]GITHUB_TOKEN Permissions")
738- for _, p := range tokenPermissionLines(j.wf, j.jb) {
745+ var lines []permissionLine
746+ if j.cfg.TokenPermissions != nil {
747+ lines = serverPermissionLines(j.cfg.TokenPermissions)
748+ } else {
749+ lines = tokenPermissionLines(j.wf, j.jb)
750+ }
751+ for _, p := range lines {
739752 j.out(1, p.name+": "+p.level)
740753 }
741754 j.out(1, "##[endgroup]")
@@ -85,6 +85,11 @@ type Config struct {
85 // SecretsWithheld says that the server did not send secrets because the run85 // SecretsWithheld says that the server did not send secrets because the run
86 // is not trusted: "Set up job" says so.86 // is not trusted: "Set up job" says so.
87 SecretsWithheld bool87 SecretsWithheld bool
88+ // TokenPermissions are the permissions of GITHUB_TOKEN as the server sent
89+ // them (Job.permissions: key to "read", "write" or "none"), which "Set up
90+ // job" prints as they are. Nil means the server sent none: they are then
91+ // worked out from the workflow. An empty, non-nil map is "none at all".
92+ TokenPermissions map[string]string
88 // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG).93 // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG).
89 Debug bool94 Debug bool
90 // Actions configures the actions of the job (resolution, downloads, Node.js,95 // Actions configures the actions of the job (resolution, downloads, Node.js,
@@ -729,13 +734,21 @@ const SecretsWithheldLine = "Secrets are not available to this job: the run was
729 // and the cache mode.734 // and the cache mode.
730 //735 //
731 // GitHub's runner prints what the server sends as736 // GitHub's runner prints what the server sends as
732-// system.github.token.permissions (JobExtension.cs:154-166); ermite's Job737+// system.github.token.permissions (JobExtension.cs:154-166). ermite's server
733-// message does not carry it, so the group is worked out from the workflow738+// sends them in Job.permissions, already merged and lowered by its trust rule:
734-// with the rules recorded on github.com (tokenPermissionLines). It is always739+// they are printed as they are (serverPermissionLines). From a server that
735-// printed: Metadata is listed even for "permissions: {}".740+// sends none the group is worked out from the workflow with the rules recorded
741+// on github.com (tokenPermissionLines). It is always printed: Metadata is listed
742+// even for "permissions: {}".
736 func (j *job) printTokenInfo() {743 func (j *job) printTokenInfo() {
737 j.out(1, "##[group]GITHUB_TOKEN Permissions")744 j.out(1, "##[group]GITHUB_TOKEN Permissions")
738- for _, p := range tokenPermissionLines(j.wf, j.jb) {745+ var lines []permissionLine
746+ if j.cfg.TokenPermissions != nil {
747+ lines = serverPermissionLines(j.cfg.TokenPermissions)
748+ } else {
749+ lines = tokenPermissionLines(j.wf, j.jb)
750+ }
751+ for _, p := range lines {
739 j.out(1, p.name+": "+p.level)752 j.out(1, p.name+": "+p.level)
740 }753 }
741 j.out(1, "##[endgroup]")754 j.out(1, "##[endgroup]")
M internal/executor/secrets_test.go +36 -0
@@ -317,3 +317,39 @@ func TestOnlyDiagUsesTheDiagnosticsLogger(t *testing.T) {
317317 }
318318 }
319319 }
320+
321+// The group prints what the server sent in Job.permissions, as GitHub's runner
322+// prints system.github.token.permissions, and not what the workflow says.
323+func TestSetUpJobPrintsTheServersTokenPermissions(t *testing.T) {
324+ needTools(t, "bash")
325+ group := func(log string) string {
326+ _, rest, _ := strings.Cut(log, "##[group]GITHUB_TOKEN Permissions\n")
327+ g, _, _ := strings.Cut(rest, "\n##[endgroup]")
328+ return g
329+ }
330+ // The workflow asks for write-all; the server lowered it (an untrusted run).
331+ yaml := "on: push\npermissions: write-all\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
332+ for _, tc := range []struct {
333+ name string
334+ levels map[string]string
335+ want string
336+ }{
337+ {"lowered by the server", map[string]string{"contents": "read", "pull-requests": "read", "issues": "read", "metadata": "read"},
338+ "Contents: read\nIssues: read\nMetadata: read\nPullRequests: read"},
339+ {"write kept", map[string]string{"contents": "write", "metadata": "read", "packages": "read"}, "Contents: write\nMetadata: read\nPackages: read"},
340+ {"none and id-token are not listed, metadata always is", map[string]string{"contents": "none", "id-token": "write", "actions": "read"},
341+ "Actions: read\nMetadata: read"},
342+ {"nothing at all is metadata only", map[string]string{}, "Metadata: read"},
343+ {"a key the runner does not know", map[string]string{"made-up-key": "read"}, "MadeUpKey: read\nMetadata: read"},
344+ } {
345+ got := group(setupLog(t, yaml, "j", func(c *Config) { c.TokenPermissions = tc.levels }))
346+ if got != tc.want {
347+ t.Errorf("%s:\n%s\nwant:\n%s", tc.name, got, tc.want)
348+ }
349+ }
350+ // A server that sends none: worked out from the workflow, as before.
351+ got := group(setupLog(t, yaml, "j", nil))
352+ if !strings.Contains(got, "Contents: write") || !strings.Contains(got, "Models: read") {
353+ t.Errorf("fallback to the workflow:\n%s", got)
354+ }
355+}
@@ -317,3 +317,39 @@ func TestOnlyDiagUsesTheDiagnosticsLogger(t *testing.T) {
317 }317 }
318 }318 }
319 }319 }
320+
321+// The group prints what the server sent in Job.permissions, as GitHub's runner
322+// prints system.github.token.permissions, and not what the workflow says.
323+func TestSetUpJobPrintsTheServersTokenPermissions(t *testing.T) {
324+ needTools(t, "bash")
325+ group := func(log string) string {
326+ _, rest, _ := strings.Cut(log, "##[group]GITHUB_TOKEN Permissions\n")
327+ g, _, _ := strings.Cut(rest, "\n##[endgroup]")
328+ return g
329+ }
330+ // The workflow asks for write-all; the server lowered it (an untrusted run).
331+ yaml := "on: push\npermissions: write-all\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
332+ for _, tc := range []struct {
333+ name string
334+ levels map[string]string
335+ want string
336+ }{
337+ {"lowered by the server", map[string]string{"contents": "read", "pull-requests": "read", "issues": "read", "metadata": "read"},
338+ "Contents: read\nIssues: read\nMetadata: read\nPullRequests: read"},
339+ {"write kept", map[string]string{"contents": "write", "metadata": "read", "packages": "read"}, "Contents: write\nMetadata: read\nPackages: read"},
340+ {"none and id-token are not listed, metadata always is", map[string]string{"contents": "none", "id-token": "write", "actions": "read"},
341+ "Actions: read\nMetadata: read"},
342+ {"nothing at all is metadata only", map[string]string{}, "Metadata: read"},
343+ {"a key the runner does not know", map[string]string{"made-up-key": "read"}, "MadeUpKey: read\nMetadata: read"},
344+ } {
345+ got := group(setupLog(t, yaml, "j", func(c *Config) { c.TokenPermissions = tc.levels }))
346+ if got != tc.want {
347+ t.Errorf("%s:\n%s\nwant:\n%s", tc.name, got, tc.want)
348+ }
349+ }
350+ // A server that sends none: worked out from the workflow, as before.
351+ got := group(setupLog(t, yaml, "j", nil))
352+ if !strings.Contains(got, "Contents: write") || !strings.Contains(got, "Models: read") {
353+ t.Errorf("fallback to the workflow:\n%s", got)
354+ }
355+}
M internal/executor/token_permissions.go +35 -0
@@ -4,6 +4,7 @@ package executor
44
55 import (
66 "sort"
7+ "strings"
78
89 "bnema.dev/ermite-actions/workflow"
910 )
@@ -106,3 +107,37 @@ func tokenPermissionLines(wf *workflow.Workflow, jb *workflow.Job) []permissionL
106107 sort.Slice(lines, func(a, b int) bool { return lines[a].name < lines[b].name })
107108 return lines
108109 }
110+
111+// serverPermissionLines returns the lines of the group for the permissions the
112+// server sent (Job.permissions): one per key at "read" or "write", sorted by
113+// display name like GitHub's runner prints them. Metadata is always listed as
114+// read. A key at "none" or absent is not listed, nor is id-token (GitHub never
115+// lists it); a key the runner does not know is listed under its name in Pascal
116+// case, as the known ones are ("pull-requests" is "PullRequests").
117+func serverPermissionLines(levels map[string]string) []permissionLine {
118+ lines := []permissionLine{{"Metadata", "read"}}
119+ for key, level := range levels {
120+ if (level != "read" && level != "write") || key == "metadata" || key == "id-token" {
121+ continue
122+ }
123+ lines = append(lines, permissionLine{displayName(key), level})
124+ }
125+ sort.Slice(lines, func(a, b int) bool { return lines[a].name < lines[b].name })
126+ return lines
127+}
128+
129+// displayName is the name "Set up job" prints for a permission key.
130+func displayName(key string) string {
131+ for _, s := range tokenScopes {
132+ if s.key == key {
133+ return s.display
134+ }
135+ }
136+ var b strings.Builder
137+ for _, part := range strings.Split(key, "-") {
138+ if part != "" {
139+ b.WriteString(strings.ToUpper(part[:1]) + part[1:])
140+ }
141+ }
142+ return b.String()
143+}
@@ -4,6 +4,7 @@ package executor
4 4
5 import (5 import (
6 "sort"6 "sort"
7+ "strings"
7 8
8 "bnema.dev/ermite-actions/workflow"9 "bnema.dev/ermite-actions/workflow"
9 )10 )
@@ -106,3 +107,37 @@ func tokenPermissionLines(wf *workflow.Workflow, jb *workflow.Job) []permissionL
106 sort.Slice(lines, func(a, b int) bool { return lines[a].name < lines[b].name })107 sort.Slice(lines, func(a, b int) bool { return lines[a].name < lines[b].name })
107 return lines108 return lines
108 }109 }
110+
111+// serverPermissionLines returns the lines of the group for the permissions the
112+// server sent (Job.permissions): one per key at "read" or "write", sorted by
113+// display name like GitHub's runner prints them. Metadata is always listed as
114+// read. A key at "none" or absent is not listed, nor is id-token (GitHub never
115+// lists it); a key the runner does not know is listed under its name in Pascal
116+// case, as the known ones are ("pull-requests" is "PullRequests").
117+func serverPermissionLines(levels map[string]string) []permissionLine {
118+ lines := []permissionLine{{"Metadata", "read"}}
119+ for key, level := range levels {
120+ if (level != "read" && level != "write") || key == "metadata" || key == "id-token" {
121+ continue
122+ }
123+ lines = append(lines, permissionLine{displayName(key), level})
124+ }
125+ sort.Slice(lines, func(a, b int) bool { return lines[a].name < lines[b].name })
126+ return lines
127+}
128+
129+// displayName is the name "Set up job" prints for a permission key.
130+func displayName(key string) string {
131+ for _, s := range tokenScopes {
132+ if s.key == key {
133+ return s.display
134+ }
135+ }
136+ var b strings.Builder
137+ for _, part := range strings.Split(key, "-") {
138+ if part != "" {
139+ b.WriteString(strings.ToUpper(part[:1]) + part[1:])
140+ }
141+ }
142+ return b.String()
143+}
M internal/guest/jobexec.go +14 -1
@@ -259,7 +259,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
259259 Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,
260260 CacheURL: cacheURL(os.Getenv("ERMITE_GATEWAY"), spec.GetCachePort()), CacheToken: spec.GetCacheToken(),
261261 Debug: spec.GetDebug(), Reporter: rep, Actions: executor.DefaultActionsConfig(res.resolve), Log: zerowrap.FromCtx(ctx),
262- Secrets: vals.secrets, Vars: vals.vars, SecretsWithheld: spec.GetSecretsWithheld(),
262+ Secrets: vals.secrets, Vars: vals.vars, SecretsWithheld: spec.GetSecretsWithheld(), TokenPermissions: tokenPermissions(spec),
263263 SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(),
264264 TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay,
265265 })
@@ -290,3 +290,16 @@ func failSetup(conn *execstream.Conn, msg string) int {
290290 rep.JobCompleted(executor.Result{Conclusion: executor.ConclusionFailure, Error: msg})
291291 return 1
292292 }
293+
294+// tokenPermissions is the permissions of GITHUB_TOKEN the server sent, nil when
295+// it sent none (the executor then works them out from the workflow).
296+func tokenPermissions(spec *execpb.JobSpec) map[string]string {
297+ p := spec.GetTokenPermissions()
298+ if p == nil {
299+ return nil
300+ }
301+ if p.GetLevels() == nil {
302+ return map[string]string{}
303+ }
304+ return p.GetLevels()
305+}
@@ -259,7 +259,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
259 Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,259 Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,
260 CacheURL: cacheURL(os.Getenv("ERMITE_GATEWAY"), spec.GetCachePort()), CacheToken: spec.GetCacheToken(),260 CacheURL: cacheURL(os.Getenv("ERMITE_GATEWAY"), spec.GetCachePort()), CacheToken: spec.GetCacheToken(),
261 Debug: spec.GetDebug(), Reporter: rep, Actions: executor.DefaultActionsConfig(res.resolve), Log: zerowrap.FromCtx(ctx),261 Debug: spec.GetDebug(), Reporter: rep, Actions: executor.DefaultActionsConfig(res.resolve), Log: zerowrap.FromCtx(ctx),
262- Secrets: vals.secrets, Vars: vals.vars, SecretsWithheld: spec.GetSecretsWithheld(),262+ Secrets: vals.secrets, Vars: vals.vars, SecretsWithheld: spec.GetSecretsWithheld(), TokenPermissions: tokenPermissions(spec),
263 SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(),263 SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(),
264 TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay,264 TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay,
265 })265 })
@@ -290,3 +290,16 @@ func failSetup(conn *execstream.Conn, msg string) int {
290 rep.JobCompleted(executor.Result{Conclusion: executor.ConclusionFailure, Error: msg})290 rep.JobCompleted(executor.Result{Conclusion: executor.ConclusionFailure, Error: msg})
291 return 1291 return 1
292 }292 }
293+
294+// tokenPermissions is the permissions of GITHUB_TOKEN the server sent, nil when
295+// it sent none (the executor then works them out from the workflow).
296+func tokenPermissions(spec *execpb.JobSpec) map[string]string {
297+ p := spec.GetTokenPermissions()
298+ if p == nil {
299+ return nil
300+ }
301+ if p.GetLevels() == nil {
302+ return map[string]string{}
303+ }
304+ return p.GetLevels()
305+}
M internal/guest/jobexec_test.go +85 -2
@@ -13,12 +13,20 @@ import (
1313 "testing"
1414 "time"
1515
16+ "google.golang.org/protobuf/proto"
17+
1618 "bnema.dev/ermite-runner/internal/execstream"
1719 "bnema.dev/ermite-runner/internal/execstream/execpb"
1820 )
1921
2022 // runJobSession starts Exec on a unix socket and drives it with a job.
2123 func runJobSession(t *testing.T, yaml string, cancelOn string, tweak func(*ExecConfig)) (steps []string, logs []*execpb.Log, res *execpb.JobResult, exit *execpb.Exit) {
24+ t.Helper()
25+ return runJobSessionSpec(t, yaml, cancelOn, tweak, nil)
26+}
27+
28+// runJobSessionSpec is runJobSession with a change to the JobSpec sent.
29+func runJobSessionSpec(t *testing.T, yaml string, cancelOn string, tweak func(*ExecConfig), spec func(*execpb.JobSpec)) (steps []string, logs []*execpb.Log, res *execpb.JobResult, exit *execpb.Exit) {
2230 t.Helper()
2331 sock := filepath.Join(t.TempDir(), "s")
2432 ln, err := net.Listen("unix", sock)
@@ -53,10 +61,14 @@ func runJobSession(t *testing.T, yaml string, cancelOn string, tweak func(*ExecC
5361 }
5462 var cancelOnce sync.Once
5563 var mu sync.Mutex
56- _, exit, err = execstream.DriveJob(conn, &execpb.Run{Job: &execpb.JobSpec{
64+ js := &execpb.JobSpec{
5765 WorkflowPath: ".github/workflows/ci.yml", WorkflowYaml: yaml, JobKey: "j", Repo: "hello", RunnerName: "r", RunnerVersion: "v",
5866 Contexts: `{"github":{"repository":"octo/hello","event_name":"push","job":"j"}}`, Matrix: "{}",
59- }}, cancel, execstream.JobHandler{
67+ }
68+ if spec != nil {
69+ spec(js)
70+ }
71+ _, exit, err = execstream.DriveJob(conn, &execpb.Run{Job: js}, cancel, execstream.JobHandler{
6072 StepStarted: func(s *execpb.StepStarted) {
6173 mu.Lock()
6274 steps = append(steps, s.Name)
@@ -331,3 +343,74 @@ func TestExecSendsResultsFrames(t *testing.T) {
331343 }
332344 }
333345 }
346+
347+// The permissions the server sent reach "Set up job" through the Run frame as
348+// the guest reads them: absent keeps the workflow's, present but empty is
349+// "Metadata only", populated is printed as sent. The frame is marshalled and
350+// parsed on the way, where a nil and an empty map would look alike.
351+func TestExecJobPrintsTheServersTokenPermissions(t *testing.T) {
352+ requireBash(t)
353+ yaml := "on: push\npermissions: write-all\njobs:\n j:\n runs-on: x\n steps:\n - run: echo hi\n"
354+ group := func(logs []*execpb.Log) string {
355+ var lines []string
356+ in := false
357+ for _, l := range logs {
358+ switch {
359+ case l.Text == "##[group]GITHUB_TOKEN Permissions":
360+ in = true
361+ case l.Text == "##[endgroup]":
362+ in = false
363+ case in:
364+ lines = append(lines, l.Text)
365+ }
366+ }
367+ return strings.Join(lines, "\n")
368+ }
369+ for _, tc := range []struct {
370+ name string
371+ set func(*execpb.JobSpec)
372+ want string
373+ }{
374+ {"absent: from the workflow", nil, "Actions: write\nArtifactMetadata: write\nAttestations: write\nChecks: write\nCodeQuality: write\nContents: write\n" +
375+ "CopilotRequests: write\nDeployments: write\nDiscussions: write\nDrives: write\nIssues: write\nMetadata: read\nModels: read\nPackages: write\n" +
376+ "Pages: write\nPullRequests: write\nRepositoryProjects: write\nSecurityEvents: write\nStatuses: write\nVulnerabilityAlerts: read"},
377+ {"present and empty: metadata only", func(s *execpb.JobSpec) { s.TokenPermissions = &execpb.TokenPermissions{} }, "Metadata: read"},
378+ {"populated: as sent", func(s *execpb.JobSpec) {
379+ s.TokenPermissions = &execpb.TokenPermissions{Levels: map[string]string{"contents": "read", "issues": "none"}}
380+ }, "Contents: read\nMetadata: read"},
381+ } {
382+ _, logs, res, _ := runJobSessionSpec(t, yaml, "", nil, tc.set)
383+ if res == nil || res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS {
384+ t.Fatalf("%s: %+v", tc.name, res)
385+ }
386+ if got := group(logs); got != tc.want {
387+ t.Errorf("%s:\n%s\nwant:\n%s", tc.name, got, tc.want)
388+ }
389+ }
390+}
391+
392+func TestTokenPermissionsNilVersusEmpty(t *testing.T) {
393+ for name, tc := range map[string]struct {
394+ spec *execpb.JobSpec
395+ wantNil bool
396+ want int
397+ }{
398+ "absent": {&execpb.JobSpec{}, true, 0},
399+ "empty": {&execpb.JobSpec{TokenPermissions: &execpb.TokenPermissions{}}, false, 0},
400+ "populated": {&execpb.JobSpec{TokenPermissions: &execpb.TokenPermissions{Levels: map[string]string{"contents": "read"}}}, false, 1},
401+ } {
402+ // After the trip a frame makes.
403+ raw, err := proto.Marshal(tc.spec)
404+ if err != nil {
405+ t.Fatal(err)
406+ }
407+ var got execpb.JobSpec
408+ if err := proto.Unmarshal(raw, &got); err != nil {
409+ t.Fatal(err)
410+ }
411+ m := tokenPermissions(&got)
412+ if (m == nil) != tc.wantNil || len(m) != tc.want {
413+ t.Errorf("%s: %#v", name, m)
414+ }
415+ }
416+}
@@ -13,12 +13,20 @@ import (
13 "testing"13 "testing"
14 "time"14 "time"
15 15
16+ "google.golang.org/protobuf/proto"
17+
16 "bnema.dev/ermite-runner/internal/execstream"18 "bnema.dev/ermite-runner/internal/execstream"
17 "bnema.dev/ermite-runner/internal/execstream/execpb"19 "bnema.dev/ermite-runner/internal/execstream/execpb"
18 )20 )
19 21
20 // runJobSession starts Exec on a unix socket and drives it with a job.22 // runJobSession starts Exec on a unix socket and drives it with a job.
21 func runJobSession(t *testing.T, yaml string, cancelOn string, tweak func(*ExecConfig)) (steps []string, logs []*execpb.Log, res *execpb.JobResult, exit *execpb.Exit) {23 func runJobSession(t *testing.T, yaml string, cancelOn string, tweak func(*ExecConfig)) (steps []string, logs []*execpb.Log, res *execpb.JobResult, exit *execpb.Exit) {
24+ t.Helper()
25+ return runJobSessionSpec(t, yaml, cancelOn, tweak, nil)
26+}
27+
28+// runJobSessionSpec is runJobSession with a change to the JobSpec sent.
29+func runJobSessionSpec(t *testing.T, yaml string, cancelOn string, tweak func(*ExecConfig), spec func(*execpb.JobSpec)) (steps []string, logs []*execpb.Log, res *execpb.JobResult, exit *execpb.Exit) {
22 t.Helper()30 t.Helper()
23 sock := filepath.Join(t.TempDir(), "s")31 sock := filepath.Join(t.TempDir(), "s")
24 ln, err := net.Listen("unix", sock)32 ln, err := net.Listen("unix", sock)
@@ -53,10 +61,14 @@ func runJobSession(t *testing.T, yaml string, cancelOn string, tweak func(*ExecC
53 }61 }
54 var cancelOnce sync.Once62 var cancelOnce sync.Once
55 var mu sync.Mutex63 var mu sync.Mutex
56- _, exit, err = execstream.DriveJob(conn, &execpb.Run{Job: &execpb.JobSpec{64+ js := &execpb.JobSpec{
57 WorkflowPath: ".github/workflows/ci.yml", WorkflowYaml: yaml, JobKey: "j", Repo: "hello", RunnerName: "r", RunnerVersion: "v",65 WorkflowPath: ".github/workflows/ci.yml", WorkflowYaml: yaml, JobKey: "j", Repo: "hello", RunnerName: "r", RunnerVersion: "v",
58 Contexts: `{"github":{"repository":"octo/hello","event_name":"push","job":"j"}}`, Matrix: "{}",66 Contexts: `{"github":{"repository":"octo/hello","event_name":"push","job":"j"}}`, Matrix: "{}",
59- }}, cancel, execstream.JobHandler{67+ }
68+ if spec != nil {
69+ spec(js)
70+ }
71+ _, exit, err = execstream.DriveJob(conn, &execpb.Run{Job: js}, cancel, execstream.JobHandler{
60 StepStarted: func(s *execpb.StepStarted) {72 StepStarted: func(s *execpb.StepStarted) {
61 mu.Lock()73 mu.Lock()
62 steps = append(steps, s.Name)74 steps = append(steps, s.Name)
@@ -331,3 +343,74 @@ func TestExecSendsResultsFrames(t *testing.T) {
331 }343 }
332 }344 }
333 }345 }
346+
347+// The permissions the server sent reach "Set up job" through the Run frame as
348+// the guest reads them: absent keeps the workflow's, present but empty is
349+// "Metadata only", populated is printed as sent. The frame is marshalled and
350+// parsed on the way, where a nil and an empty map would look alike.
351+func TestExecJobPrintsTheServersTokenPermissions(t *testing.T) {
352+ requireBash(t)
353+ yaml := "on: push\npermissions: write-all\njobs:\n j:\n runs-on: x\n steps:\n - run: echo hi\n"
354+ group := func(logs []*execpb.Log) string {
355+ var lines []string
356+ in := false
357+ for _, l := range logs {
358+ switch {
359+ case l.Text == "##[group]GITHUB_TOKEN Permissions":
360+ in = true
361+ case l.Text == "##[endgroup]":
362+ in = false
363+ case in:
364+ lines = append(lines, l.Text)
365+ }
366+ }
367+ return strings.Join(lines, "\n")
368+ }
369+ for _, tc := range []struct {
370+ name string
371+ set func(*execpb.JobSpec)
372+ want string
373+ }{
374+ {"absent: from the workflow", nil, "Actions: write\nArtifactMetadata: write\nAttestations: write\nChecks: write\nCodeQuality: write\nContents: write\n" +
375+ "CopilotRequests: write\nDeployments: write\nDiscussions: write\nDrives: write\nIssues: write\nMetadata: read\nModels: read\nPackages: write\n" +
376+ "Pages: write\nPullRequests: write\nRepositoryProjects: write\nSecurityEvents: write\nStatuses: write\nVulnerabilityAlerts: read"},
377+ {"present and empty: metadata only", func(s *execpb.JobSpec) { s.TokenPermissions = &execpb.TokenPermissions{} }, "Metadata: read"},
378+ {"populated: as sent", func(s *execpb.JobSpec) {
379+ s.TokenPermissions = &execpb.TokenPermissions{Levels: map[string]string{"contents": "read", "issues": "none"}}
380+ }, "Contents: read\nMetadata: read"},
381+ } {
382+ _, logs, res, _ := runJobSessionSpec(t, yaml, "", nil, tc.set)
383+ if res == nil || res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS {
384+ t.Fatalf("%s: %+v", tc.name, res)
385+ }
386+ if got := group(logs); got != tc.want {
387+ t.Errorf("%s:\n%s\nwant:\n%s", tc.name, got, tc.want)
388+ }
389+ }
390+}
391+
392+func TestTokenPermissionsNilVersusEmpty(t *testing.T) {
393+ for name, tc := range map[string]struct {
394+ spec *execpb.JobSpec
395+ wantNil bool
396+ want int
397+ }{
398+ "absent": {&execpb.JobSpec{}, true, 0},
399+ "empty": {&execpb.JobSpec{TokenPermissions: &execpb.TokenPermissions{}}, false, 0},
400+ "populated": {&execpb.JobSpec{TokenPermissions: &execpb.TokenPermissions{Levels: map[string]string{"contents": "read"}}}, false, 1},
401+ } {
402+ // After the trip a frame makes.
403+ raw, err := proto.Marshal(tc.spec)
404+ if err != nil {
405+ t.Fatal(err)
406+ }
407+ var got execpb.JobSpec
408+ if err := proto.Unmarshal(raw, &got); err != nil {
409+ t.Fatal(err)
410+ }
411+ m := tokenPermissions(&got)
412+ if (m == nil) != tc.wantNil || len(m) != tc.want {
413+ t.Errorf("%s: %#v", name, m)
414+ }
415+ }
416+}
M internal/host/job.go +3 -0
@@ -697,6 +697,9 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) {
697697 // only when the server has ci.secrets.
698698 SecretsWithheld: j.features.secrets && !j.job.Trusted,
699699 }}
700+ if p := j.job.Permissions; p != nil && j.features.secrets {
701+ run.Job.TokenPermissions = &execpb.TokenPermissions{Levels: p.GetLevels()}
702+ }
700703 run.Job.CachePort, run.Job.CacheToken = j.cacheFor(rt)
701704 _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{
702705 StepStarted: j.stepStarted, StepCompleted: j.stepCompleted,
@@ -697,6 +697,9 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) {
697 // only when the server has ci.secrets.697 // only when the server has ci.secrets.
698 SecretsWithheld: j.features.secrets && !j.job.Trusted,698 SecretsWithheld: j.features.secrets && !j.job.Trusted,
699 }}699 }}
700+ if p := j.job.Permissions; p != nil && j.features.secrets {
701+ run.Job.TokenPermissions = &execpb.TokenPermissions{Levels: p.GetLevels()}
702+ }
700 run.Job.CachePort, run.Job.CacheToken = j.cacheFor(rt)703 run.Job.CachePort, run.Job.CacheToken = j.cacheFor(rt)
701 _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{704 _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{
702 StepStarted: j.stepStarted, StepCompleted: j.stepCompleted,705 StepStarted: j.stepStarted, StepCompleted: j.stepCompleted,
M internal/host/secrets_test.go +47 -0
@@ -9,6 +9,7 @@ import (
99 "go/format"
1010 "go/parser"
1111 "go/token"
12+ "maps"
1213 "os"
1314 "path/filepath"
1415 "regexp"
@@ -17,6 +18,8 @@ import (
1718
1819 "google.golang.org/protobuf/proto"
1920
21+ ermite "bnema.dev/ermite-api/gen/ermite"
22+
2023 "bnema.dev/ermite-runner/internal/execstream/execpb"
2124 )
2225
@@ -300,3 +303,47 @@ func TestPrintLeaksHeuristic(t *testing.T) {
300303 }
301304 }
302305 }
306+
307+// Job.permissions reaches the executor in the Run frame when the server has
308+// ci.secrets (it sent them); an old server's absent field stays unset, so that
309+// the executor works them out from the workflow.
310+func TestTokenPermissionsReachTheExecutor(t *testing.T) {
311+ for _, tc := range []struct {
312+ name string
313+ features []string
314+ perms *ermite.JobPermissions
315+ want map[string]string // nil: unset in the frame
316+ }{
317+ {"sent", []string{FeatureSecrets}, &ermite.JobPermissions{Levels: map[string]string{"contents": "write", "metadata": "read"}},
318+ map[string]string{"contents": "write", "metadata": "read"}},
319+ {"sent and empty", []string{FeatureSecrets}, &ermite.JobPermissions{}, map[string]string{}},
320+ {"not sent", []string{FeatureSecrets}, nil, nil},
321+ {"old server", []string{FeatureAnnotations}, &ermite.JobPermissions{Levels: map[string]string{"contents": "write"}}, nil},
322+ } {
323+ t.Run(tc.name, func(t *testing.T) {
324+ seenRun.Store(nil)
325+ h := newHarness(t, resultsScript(nil))
326+ h.runner.Cfg.StateDir = t.TempDir()
327+ h.runner.Features = func(context.Context) ([]string, error) { return tc.features, nil }
328+ j := testJob("j1")
329+ j.Permissions = tc.perms
330+ h.srv.jobs = []*ermite.Job{j}
331+ stop := h.run(t)
332+ eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 })
333+ if err := stop(); err != nil {
334+ t.Fatal(err)
335+ }
336+ run := seenRun.Load()
337+ if run == nil {
338+ t.Fatal("no Run frame")
339+ }
340+ got := run.Job.GetTokenPermissions()
341+ switch {
342+ case tc.want == nil && got != nil:
343+ t.Errorf("unexpected permissions %v", got.GetLevels())
344+ case tc.want != nil && (got == nil || !maps.Equal(got.GetLevels(), tc.want)):
345+ t.Errorf("permissions %v, want %v", got.GetLevels(), tc.want)
346+ }
347+ })
348+ }
349+}
@@ -9,6 +9,7 @@ import (
9 "go/format"9 "go/format"
10 "go/parser"10 "go/parser"
11 "go/token"11 "go/token"
12+ "maps"
12 "os"13 "os"
13 "path/filepath"14 "path/filepath"
14 "regexp"15 "regexp"
@@ -17,6 +18,8 @@ import (
17 18
18 "google.golang.org/protobuf/proto"19 "google.golang.org/protobuf/proto"
19 20
21+ ermite "bnema.dev/ermite-api/gen/ermite"
22+
20 "bnema.dev/ermite-runner/internal/execstream/execpb"23 "bnema.dev/ermite-runner/internal/execstream/execpb"
21 )24 )
22 25
@@ -300,3 +303,47 @@ func TestPrintLeaksHeuristic(t *testing.T) {
300 }303 }
301 }304 }
302 }305 }
306+
307+// Job.permissions reaches the executor in the Run frame when the server has
308+// ci.secrets (it sent them); an old server's absent field stays unset, so that
309+// the executor works them out from the workflow.
310+func TestTokenPermissionsReachTheExecutor(t *testing.T) {
311+ for _, tc := range []struct {
312+ name string
313+ features []string
314+ perms *ermite.JobPermissions
315+ want map[string]string // nil: unset in the frame
316+ }{
317+ {"sent", []string{FeatureSecrets}, &ermite.JobPermissions{Levels: map[string]string{"contents": "write", "metadata": "read"}},
318+ map[string]string{"contents": "write", "metadata": "read"}},
319+ {"sent and empty", []string{FeatureSecrets}, &ermite.JobPermissions{}, map[string]string{}},
320+ {"not sent", []string{FeatureSecrets}, nil, nil},
321+ {"old server", []string{FeatureAnnotations}, &ermite.JobPermissions{Levels: map[string]string{"contents": "write"}}, nil},
322+ } {
323+ t.Run(tc.name, func(t *testing.T) {
324+ seenRun.Store(nil)
325+ h := newHarness(t, resultsScript(nil))
326+ h.runner.Cfg.StateDir = t.TempDir()
327+ h.runner.Features = func(context.Context) ([]string, error) { return tc.features, nil }
328+ j := testJob("j1")
329+ j.Permissions = tc.perms
330+ h.srv.jobs = []*ermite.Job{j}
331+ stop := h.run(t)
332+ eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 })
333+ if err := stop(); err != nil {
334+ t.Fatal(err)
335+ }
336+ run := seenRun.Load()
337+ if run == nil {
338+ t.Fatal("no Run frame")
339+ }
340+ got := run.Job.GetTokenPermissions()
341+ switch {
342+ case tc.want == nil && got != nil:
343+ t.Errorf("unexpected permissions %v", got.GetLevels())
344+ case tc.want != nil && (got == nil || !maps.Equal(got.GetLevels(), tc.want)):
345+ t.Errorf("permissions %v, want %v", got.GetLevels(), tc.want)
346+ }
347+ })
348+ }
349+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close