detached 9517f29

feat(ci): OIDC ID token request variables (#34)

commit
9517f293ae373d69fe9b1d70d9b3d6d84a54dd93
author
brice · · 2026-10-16 08:48 UTC
committer
bnema · · 2026-10-16 08:48 UTC
parent
0e950d6
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
13 files · +374 -10
M internal/execstream/exec.proto +11 -0
@@ -109,6 +109,17 @@ message JobSpec {
109109 // secrets are not available. Never set for servers without the feature, for
110110 // which an absent Job.trusted only means "not sent".
111111 bool secrets_withheld = 19;
112+ // id_token_url and id_token_request_token are Job.id_token_url and
113+ // Job.id_token_request_token: the OIDC token endpoint of the server, its
114+ // origin rewritten as for github.server_url, and the bearer for it. The
115+ // executor gives them as ACTIONS_ID_TOKEN_REQUEST_URL and
116+ // ACTIONS_ID_TOKEN_REQUEST_TOKEN to run steps, node actions and docker
117+ // actions (ScriptHandler.cs:315-319, NodeScriptActionHandler.cs:66-70,
118+ // ContainerActionHandler.cs:232-236), and masks the token before anything
119+ // runs. Both are set or neither. (21 and 22 are taken by the artifacts
120+ // branch.)
121+ string id_token_url = 23;
122+ string id_token_request_token = 24;
112123 // token_permissions are the effective permissions of the job's GITHUB_TOKEN
113124 // as the server sent them (Job.permissions, feature ci.secrets): "Set up job"
114125 // prints them instead of working them out from the workflow. Unset when the
@@ -109,6 +109,17 @@ message JobSpec {
109 // secrets are not available. Never set for servers without the feature, for109 // secrets are not available. Never set for servers without the feature, for
110 // which an absent Job.trusted only means "not sent".110 // which an absent Job.trusted only means "not sent".
111 bool secrets_withheld = 19;111 bool secrets_withheld = 19;
112+ // id_token_url and id_token_request_token are Job.id_token_url and
113+ // Job.id_token_request_token: the OIDC token endpoint of the server, its
114+ // origin rewritten as for github.server_url, and the bearer for it. The
115+ // executor gives them as ACTIONS_ID_TOKEN_REQUEST_URL and
116+ // ACTIONS_ID_TOKEN_REQUEST_TOKEN to run steps, node actions and docker
117+ // actions (ScriptHandler.cs:315-319, NodeScriptActionHandler.cs:66-70,
118+ // ContainerActionHandler.cs:232-236), and masks the token before anything
119+ // runs. Both are set or neither. (21 and 22 are taken by the artifacts
120+ // branch.)
121+ string id_token_url = 23;
122+ string id_token_request_token = 24;
112 // token_permissions are the effective permissions of the job's GITHUB_TOKEN123 // token_permissions are the effective permissions of the job's GITHUB_TOKEN
113 // as the server sent them (Job.permissions, feature ci.secrets): "Set up job"124 // as the server sent them (Job.permissions, feature ci.secrets): "Set up job"
114 // prints them instead of working them out from the workflow. Unset when the125 // prints them instead of working them out from the workflow. Unset when the
M internal/execstream/execpb/exec.pb.go +30 -2
@@ -758,6 +758,17 @@ type JobSpec struct {
758758 // secrets are not available. Never set for servers without the feature, for
759759 // which an absent Job.trusted only means "not sent".
760760 SecretsWithheld bool `protobuf:"varint,19,opt,name=secrets_withheld,json=secretsWithheld,proto3" json:"secrets_withheld,omitempty"`
761+ // id_token_url and id_token_request_token are Job.id_token_url and
762+ // Job.id_token_request_token: the OIDC token endpoint of the server, its
763+ // origin rewritten as for github.server_url, and the bearer for it. The
764+ // executor gives them as ACTIONS_ID_TOKEN_REQUEST_URL and
765+ // ACTIONS_ID_TOKEN_REQUEST_TOKEN to run steps, node actions and docker
766+ // actions (ScriptHandler.cs:315-319, NodeScriptActionHandler.cs:66-70,
767+ // ContainerActionHandler.cs:232-236), and masks the token before anything
768+ // runs. Both are set or neither. (21 and 22 are taken by the artifacts
769+ // branch.)
770+ IdTokenUrl string `protobuf:"bytes,23,opt,name=id_token_url,json=idTokenUrl,proto3" json:"id_token_url,omitempty"`
771+ IdTokenRequestToken string `protobuf:"bytes,24,opt,name=id_token_request_token,json=idTokenRequestToken,proto3" json:"id_token_request_token,omitempty"`
761772 // token_permissions are the effective permissions of the job's GITHUB_TOKEN
762773 // as the server sent them (Job.permissions, feature ci.secrets): "Set up job"
763774 // prints them instead of working them out from the workflow. Unset when the
@@ -930,6 +941,20 @@ func (x *JobSpec) GetSecretsWithheld() bool {
930941 return false
931942 }
932943
944+func (x *JobSpec) GetIdTokenUrl() string {
945+ if x != nil {
946+ return x.IdTokenUrl
947+ }
948+ return ""
949+}
950+
951+func (x *JobSpec) GetIdTokenRequestToken() string {
952+ if x != nil {
953+ return x.IdTokenRequestToken
954+ }
955+ return ""
956+}
957+
933958 func (x *JobSpec) GetTokenPermissions() *TokenPermissions {
934959 if x != nil {
935960 return x.TokenPermissions
@@ -1932,7 +1957,7 @@ const file_exec_proto_rawDesc = "" +
19321957 "\x03job\x18\x03 \x01(\v2\x1e.ermite.runner.exec.v1.JobSpecR\x03job\x1a6\n" +
19331958 "\bEnvEntry\x12\x10\n" +
19341959 "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
1935- "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xf2\x05\n" +
1960+ "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xc9\x06\n" +
19361961 "\aJobSpec\x12#\n" +
19371962 "\rworkflow_path\x18\x01 \x01(\tR\fworkflowPath\x12#\n" +
19381963 "\rworkflow_yaml\x18\x02 \x01(\tR\fworkflowYaml\x12\x17\n" +
@@ -1955,7 +1980,10 @@ const file_exec_proto_rawDesc = "" +
19551980 "cache_port\x18\x0f \x01(\rR\tcachePort\x12\x1f\n" +
19561981 "\vcache_token\x18\x10 \x01(\tR\n" +
19571982 "cacheToken\x12)\n" +
1958- "\x10secrets_withheld\x18\x13 \x01(\bR\x0fsecretsWithheld\x12T\n" +
1983+ "\x10secrets_withheld\x18\x13 \x01(\bR\x0fsecretsWithheld\x12 \n" +
1984+ "\fid_token_url\x18\x17 \x01(\tR\n" +
1985+ "idTokenUrl\x123\n" +
1986+ "\x16id_token_request_token\x18\x18 \x01(\tR\x13idTokenRequestToken\x12T\n" +
19591987 "\x11token_permissions\x18\x14 \x01(\v2'.ermite.runner.exec.v1.TokenPermissionsR\x10tokenPermissions\x12\x1d\n" +
19601988 "\n" +
19611989 "cache_mode\x18\x15 \x01(\tR\tcacheMode\x12\x18\n" +
@@ -758,6 +758,17 @@ type JobSpec struct {
758 // secrets are not available. Never set for servers without the feature, for758 // secrets are not available. Never set for servers without the feature, for
759 // which an absent Job.trusted only means "not sent".759 // which an absent Job.trusted only means "not sent".
760 SecretsWithheld bool `protobuf:"varint,19,opt,name=secrets_withheld,json=secretsWithheld,proto3" json:"secrets_withheld,omitempty"`760 SecretsWithheld bool `protobuf:"varint,19,opt,name=secrets_withheld,json=secretsWithheld,proto3" json:"secrets_withheld,omitempty"`
761+ // id_token_url and id_token_request_token are Job.id_token_url and
762+ // Job.id_token_request_token: the OIDC token endpoint of the server, its
763+ // origin rewritten as for github.server_url, and the bearer for it. The
764+ // executor gives them as ACTIONS_ID_TOKEN_REQUEST_URL and
765+ // ACTIONS_ID_TOKEN_REQUEST_TOKEN to run steps, node actions and docker
766+ // actions (ScriptHandler.cs:315-319, NodeScriptActionHandler.cs:66-70,
767+ // ContainerActionHandler.cs:232-236), and masks the token before anything
768+ // runs. Both are set or neither. (21 and 22 are taken by the artifacts
769+ // branch.)
770+ IdTokenUrl string `protobuf:"bytes,23,opt,name=id_token_url,json=idTokenUrl,proto3" json:"id_token_url,omitempty"`
771+ IdTokenRequestToken string `protobuf:"bytes,24,opt,name=id_token_request_token,json=idTokenRequestToken,proto3" json:"id_token_request_token,omitempty"`
761 // token_permissions are the effective permissions of the job's GITHUB_TOKEN772 // token_permissions are the effective permissions of the job's GITHUB_TOKEN
762 // as the server sent them (Job.permissions, feature ci.secrets): "Set up job"773 // as the server sent them (Job.permissions, feature ci.secrets): "Set up job"
763 // prints them instead of working them out from the workflow. Unset when the774 // prints them instead of working them out from the workflow. Unset when the
@@ -930,6 +941,20 @@ func (x *JobSpec) GetSecretsWithheld() bool {
930 return false941 return false
931 }942 }
932 943
944+func (x *JobSpec) GetIdTokenUrl() string {
945+ if x != nil {
946+ return x.IdTokenUrl
947+ }
948+ return ""
949+}
950+
951+func (x *JobSpec) GetIdTokenRequestToken() string {
952+ if x != nil {
953+ return x.IdTokenRequestToken
954+ }
955+ return ""
956+}
957+
933 func (x *JobSpec) GetTokenPermissions() *TokenPermissions {958 func (x *JobSpec) GetTokenPermissions() *TokenPermissions {
934 if x != nil {959 if x != nil {
935 return x.TokenPermissions960 return x.TokenPermissions
@@ -1932,7 +1957,7 @@ const file_exec_proto_rawDesc = "" +
1932 "\x03job\x18\x03 \x01(\v2\x1e.ermite.runner.exec.v1.JobSpecR\x03job\x1a6\n" +1957 "\x03job\x18\x03 \x01(\v2\x1e.ermite.runner.exec.v1.JobSpecR\x03job\x1a6\n" +
1933 "\bEnvEntry\x12\x10\n" +1958 "\bEnvEntry\x12\x10\n" +
1934 "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +1959 "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
1935- "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xf2\x05\n" +1960+ "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xc9\x06\n" +
1936 "\aJobSpec\x12#\n" +1961 "\aJobSpec\x12#\n" +
1937 "\rworkflow_path\x18\x01 \x01(\tR\fworkflowPath\x12#\n" +1962 "\rworkflow_path\x18\x01 \x01(\tR\fworkflowPath\x12#\n" +
1938 "\rworkflow_yaml\x18\x02 \x01(\tR\fworkflowYaml\x12\x17\n" +1963 "\rworkflow_yaml\x18\x02 \x01(\tR\fworkflowYaml\x12\x17\n" +
@@ -1955,7 +1980,10 @@ const file_exec_proto_rawDesc = "" +
1955 "cache_port\x18\x0f \x01(\rR\tcachePort\x12\x1f\n" +1980 "cache_port\x18\x0f \x01(\rR\tcachePort\x12\x1f\n" +
1956 "\vcache_token\x18\x10 \x01(\tR\n" +1981 "\vcache_token\x18\x10 \x01(\tR\n" +
1957 "cacheToken\x12)\n" +1982 "cacheToken\x12)\n" +
1958- "\x10secrets_withheld\x18\x13 \x01(\bR\x0fsecretsWithheld\x12T\n" +1983+ "\x10secrets_withheld\x18\x13 \x01(\bR\x0fsecretsWithheld\x12 \n" +
1984+ "\fid_token_url\x18\x17 \x01(\tR\n" +
1985+ "idTokenUrl\x123\n" +
1986+ "\x16id_token_request_token\x18\x18 \x01(\tR\x13idTokenRequestToken\x12T\n" +
1959 "\x11token_permissions\x18\x14 \x01(\v2'.ermite.runner.exec.v1.TokenPermissionsR\x10tokenPermissions\x12\x1d\n" +1987 "\x11token_permissions\x18\x14 \x01(\v2'.ermite.runner.exec.v1.TokenPermissionsR\x10tokenPermissions\x12\x1d\n" +
1960 "\n" +1988 "\n" +
1961 "cache_mode\x18\x15 \x01(\tR\tcacheMode\x12\x18\n" +1989 "cache_mode\x18\x15 \x01(\tR\tcacheMode\x12\x18\n" +
M internal/executor/actions_run.go +3 -0
@@ -206,6 +206,9 @@ func (j *job) runAction(s *stepRun, cancel <-chan struct{}) stepResult {
206206 return fail(err)
207207 }
208208 j.printDetails(s, in, env)
209+ if def.kind == kindNode || def.kind == kindDocker {
210+ j.addIDTokenEnv(env)
211+ }
209212 switch def.kind {
210213 case kindNode:
211214 return j.runNode(s, def, in, env, cancel)
@@ -206,6 +206,9 @@ func (j *job) runAction(s *stepRun, cancel <-chan struct{}) stepResult {
206 return fail(err)206 return fail(err)
207 }207 }
208 j.printDetails(s, in, env)208 j.printDetails(s, in, env)
209+ if def.kind == kindNode || def.kind == kindDocker {
210+ j.addIDTokenEnv(env)
211+ }
209 switch def.kind {212 switch def.kind {
210 case kindNode:213 case kindNode:
211 return j.runNode(s, def, in, env, cancel)214 return j.runNode(s, def, in, env, cancel)
M internal/executor/containers_exec.go +1 -1
@@ -101,7 +101,7 @@ func (j *job) containerScript(c *container, plan ShellPlan, in scriptIn, s *step
101101 if len(sc.Argv) > 1 {
102102 rest = sc.Argv[1:]
103103 }
104- henv := j.handlerEnv(s.env)
104+ henv := j.handlerEnv(s.actEnv)
105105 if err := checkEnvNames(henv.Keys()); err != nil {
106106 return nil, err
107107 }
@@ -101,7 +101,7 @@ func (j *job) containerScript(c *container, plan ShellPlan, in scriptIn, s *step
101 if len(sc.Argv) > 1 {101 if len(sc.Argv) > 1 {
102 rest = sc.Argv[1:]102 rest = sc.Argv[1:]
103 }103 }
104- henv := j.handlerEnv(s.env)104+ henv := j.handlerEnv(s.actEnv)
105 if err := checkEnvNames(henv.Keys()); err != nil {105 if err := checkEnvNames(henv.Keys()); err != nil {
106 return nil, err106 return nil, err
107 }107 }
M internal/executor/exec_step.go +2 -0
@@ -38,6 +38,8 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
3838 if err != nil {
3939 return fail(err) // the group stays open, like the original
4040 }
41+ // What the handler adds goes to the process only, after the header.
42+ s.actEnv = j.scriptEnv(s)
4143 if c := j.stepContainer(); c != nil {
4244 argv, err := j.containerScript(c, plan, in, s)
4345 if err != nil {
@@ -38,6 +38,8 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
38 if err != nil {38 if err != nil {
39 return fail(err) // the group stays open, like the original39 return fail(err) // the group stays open, like the original
40 }40 }
41+ // What the handler adds goes to the process only, after the header.
42+ s.actEnv = j.scriptEnv(s)
41 if c := j.stepContainer(); c != nil {43 if c := j.stepContainer(); c != nil {
42 argv, err := j.containerScript(c, plan, in, s)44 argv, err := j.containerScript(c, plan, in, s)
43 if err != nil {45 if err != nil {
A internal/executor/idtoken.go +27 -0
@@ -0,0 +1,27 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+// addIDTokenEnv is the OIDC part of the handlers (ScriptHandler.cs:315-319,
6+// NodeScriptActionHandler.cs:66-70, ContainerActionHandler.cs:232-236):
7+// ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN, which
8+// @actions/core's getIDToken reads, for run steps, node actions and docker
9+// actions. A composite action sets nothing itself: its steps get them from
10+// their own handlers. Nothing is set unless the job has both (the server gives
11+// them only to a trusted job with "id-token: write").
12+func (j *job) addIDTokenEnv(env *ordered) {
13+ if j.cfg.IDTokenURL == "" || j.cfg.IDTokenRequestToken == "" {
14+ return
15+ }
16+ env.Set("ACTIONS_ID_TOKEN_REQUEST_URL", j.cfg.IDTokenURL)
17+ env.Set("ACTIONS_ID_TOKEN_REQUEST_TOKEN", j.cfg.IDTokenRequestToken)
18+}
19+
20+// scriptEnv returns the environment of the process of a run step: the env of
21+// the step, plus the variables its handler adds (addIDTokenEnv). The env of
22+// the step itself, which the env context and the header show, is left alone.
23+func (j *job) scriptEnv(s *stepRun) *ordered {
24+ env := s.env.Clone()
25+ j.addIDTokenEnv(env)
26+ return env
27+}
@@ -0,0 +1,27 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+// addIDTokenEnv is the OIDC part of the handlers (ScriptHandler.cs:315-319,
6+// NodeScriptActionHandler.cs:66-70, ContainerActionHandler.cs:232-236):
7+// ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN, which
8+// @actions/core's getIDToken reads, for run steps, node actions and docker
9+// actions. A composite action sets nothing itself: its steps get them from
10+// their own handlers. Nothing is set unless the job has both (the server gives
11+// them only to a trusted job with "id-token: write").
12+func (j *job) addIDTokenEnv(env *ordered) {
13+ if j.cfg.IDTokenURL == "" || j.cfg.IDTokenRequestToken == "" {
14+ return
15+ }
16+ env.Set("ACTIONS_ID_TOKEN_REQUEST_URL", j.cfg.IDTokenURL)
17+ env.Set("ACTIONS_ID_TOKEN_REQUEST_TOKEN", j.cfg.IDTokenRequestToken)
18+}
19+
20+// scriptEnv returns the environment of the process of a run step: the env of
21+// the step, plus the variables its handler adds (addIDTokenEnv). The env of
22+// the step itself, which the env context and the header show, is left alone.
23+func (j *job) scriptEnv(s *stepRun) *ordered {
24+ env := s.env.Clone()
25+ j.addIDTokenEnv(env)
26+ return env
27+}
A internal/executor/idtoken_test.go +160 -0
@@ -0,0 +1,160 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+ "testing"
8+)
9+
10+const (
11+ idTokenTestURL = "http://ermite.example:8088/-/oidc/token?api-version=2.0"
12+ idTokenTestToken = "ermiteoidc_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
13+)
14+
15+func withIDToken(c *Config) {
16+ c.IDTokenURL, c.IDTokenRequestToken = idTokenTestURL, idTokenTestToken
17+}
18+
19+const idTokenEcho = `echo "oidc url=[${ACTIONS_ID_TOKEN_REQUEST_URL-unset}] token=[${ACTIONS_ID_TOKEN_REQUEST_TOKEN-unset}]"`
20+
21+const idTokenAction = "name: e\ndescription: d\nruns:\n using: node20\n main: main.sh\n"
22+
23+var idTokenWant = "oidc url=[" + idTokenTestURL + "] token=[***]"
24+
25+const idTokenUnset = "oidc url=[unset] token=[unset]"
26+
27+// ScriptHandler.cs:315-319: a run step gets ACTIONS_ID_TOKEN_REQUEST_URL and
28+// ACTIONS_ID_TOKEN_REQUEST_TOKEN in its process, the token masked. The env of
29+// the step (its header, the env context) does not show them.
30+func TestRunStepGetsIDTokenEnv(t *testing.T) {
31+ rec, res := actionRun(t, files{},
32+ " - name: r\n env:\n FOO: bar\n run: |\n "+idTokenEcho+"\n echo \"ctx=[${{ env.ACTIONS_ID_TOKEN_REQUEST_URL }}]\"\n", withIDToken)
33+ if res.Conclusion != ConclusionSuccess {
34+ t.Fatalf("%+v", res)
35+ }
36+ got := logOf(rec.step(t, "r"))
37+ if !strings.Contains(got, idTokenWant) || !strings.Contains(got, "ctx=[]") {
38+ t.Errorf("want %q and an empty env context in\n%s", idTokenWant, got)
39+ }
40+ if strings.Contains(got, idTokenTestToken) || strings.Contains(got, " ACTIONS_ID_TOKEN_REQUEST_URL:") {
41+ t.Errorf("the token or the variables are in the log:\n%s", got)
42+ }
43+}
44+
45+// NodeScriptActionHandler.cs:66-70: a node action gets them too, without a
46+// cache service, and they are not in the env block of its header.
47+func TestNodeActionGetsIDTokenEnv(t *testing.T) {
48+ rec, res := actionRun(t, files{".github/actions/e/action.yml": idTokenAction, ".github/actions/e/main.sh": idTokenEcho + "\n"},
49+ " - uses: ./.github/actions/e\n", withIDToken)
50+ if res.Conclusion != ConclusionSuccess {
51+ t.Fatalf("%+v", res)
52+ }
53+ got := logOf(rec.step(t, "Run ./.github/actions/e"))
54+ if !strings.Contains(got, idTokenWant) {
55+ t.Errorf("want %q in\n%s", idTokenWant, got)
56+ }
57+ if strings.Contains(got, idTokenTestToken) || strings.Contains(got, " ACTIONS_ID_TOKEN_REQUEST_URL:") {
58+ t.Errorf("the token or the variables are in the log:\n%s", got)
59+ }
60+}
61+
62+// A composite action sets nothing itself; its steps get the variables from
63+// their own handlers, as on GitHub.
64+func TestCompositeStepsGetIDTokenEnv(t *testing.T) {
65+ composite := "name: c\ndescription: d\nruns:\n using: composite\n steps:\n - shell: bash\n run: '" + idTokenEcho + "'\n"
66+ rec, res := actionRun(t, files{".github/actions/c/action.yml": composite}, " - uses: ./.github/actions/c\n", withIDToken)
67+ if res.Conclusion != ConclusionSuccess {
68+ t.Fatalf("%+v", res)
69+ }
70+ if got := logOf(rec.step(t, "Run ./.github/actions/c")); !strings.Contains(got, idTokenWant) || strings.Contains(got, idTokenTestToken) {
71+ t.Errorf("want %q in\n%s", idTokenWant, got)
72+ }
73+}
74+
75+// ContainerActionHandler.cs:232-236: a docker action gets them by name on the
76+// "docker run" command line, never by value.
77+func TestDockerActionGetsIDTokenEnv(t *testing.T) {
78+ d, lf := fakeDocker(t, 0)
79+ rec, res := actionRun(t, files{}, " - uses: docker://alpine:3.20\n", func(c *Config) {
80+ c.Actions.DockerPath = d
81+ withIDToken(c)
82+ })
83+ if res.Conclusion != ConclusionSuccess {
84+ t.Fatalf("%+v %v", res, rec.names())
85+ }
86+ cs := calls(t, lf)
87+ if len(cs) != 2 {
88+ t.Fatalf("calls %v", cs)
89+ }
90+ args := "\n" + strings.Join(cs[1], "\n") + "\n"
91+ for _, n := range []string{"ACTIONS_ID_TOKEN_REQUEST_URL", "ACTIONS_ID_TOKEN_REQUEST_TOKEN"} {
92+ if !strings.Contains(args, "\n-e\n"+n+"\n") {
93+ t.Errorf("docker run lacks -e %s:\n%s", n, args)
94+ }
95+ }
96+ if strings.Contains(args, idTokenTestToken) {
97+ t.Errorf("the token value is on the command line:\n%s", args)
98+ }
99+}
100+
101+// A run step in the job container gets them through "docker exec -e NAME".
102+func TestRunStepInContainerGetsIDTokenEnv(t *testing.T) {
103+ f := newFakeCtr(t)
104+ rec, res := ctrRun(t, f, " container: ubuntu:24.04\n", " - name: s\n run: echo hi\n", withIDToken)
105+ if res.Conclusion != ConclusionSuccess {
106+ t.Fatalf("%+v %v", res, rec.names())
107+ }
108+ ex := f.execCalls(t)
109+ if len(ex) != 1 {
110+ t.Fatalf("exec calls %v", ex)
111+ }
112+ c := "\n" + strings.Join(ex[0], "\n") + "\n"
113+ for _, n := range []string{"ACTIONS_ID_TOKEN_REQUEST_URL", "ACTIONS_ID_TOKEN_REQUEST_TOKEN"} {
114+ if !strings.Contains(c, "\n-e\n"+n+"\n") {
115+ t.Errorf("exec lacks -e %s:\n%s", n, c)
116+ }
117+ }
118+ if strings.Contains(c, idTokenTestToken) {
119+ t.Errorf("the token value is on the command line:\n%s", c)
120+ }
121+}
122+
123+// Nothing is set unless the job has both the URL and the token.
124+func TestIDTokenEnvNeedsBoth(t *testing.T) {
125+ for name, tweak := range map[string]func(*Config){
126+ "none": nil,
127+ "url only": func(c *Config) { c.IDTokenURL = idTokenTestURL },
128+ "token only": func(c *Config) { c.IDTokenRequestToken = idTokenTestToken },
129+ } {
130+ t.Run(name, func(t *testing.T) {
131+ rec, res := actionRun(t, files{".github/actions/e/action.yml": idTokenAction, ".github/actions/e/main.sh": idTokenEcho + "\n"},
132+ " - name: r\n run: '"+idTokenEcho+"'\n - uses: ./.github/actions/e\n", tweak)
133+ if res.Conclusion != ConclusionSuccess {
134+ t.Fatalf("%+v", res)
135+ }
136+ for _, s := range []string{"r", "Run ./.github/actions/e"} {
137+ if got := logOf(rec.step(t, s)); !strings.Contains(got, idTokenUnset) {
138+ t.Errorf("%s: want %q in\n%s", s, idTokenUnset, got)
139+ }
140+ }
141+ })
142+ }
143+}
144+
145+// The token is in the masker before the first step: a step that prints it,
146+// even by another name, shows ***.
147+func TestIDTokenIsMasked(t *testing.T) {
148+ rec, res := actionRun(t, files{}, " - name: r\n run: echo \"leak "+idTokenTestToken+"\"\n", withIDToken)
149+ if res.Conclusion != ConclusionSuccess {
150+ t.Fatalf("%+v", res)
151+ }
152+ for _, s := range rec.steps {
153+ if got := logOf(s); strings.Contains(got, idTokenTestToken) {
154+ t.Errorf("the token is in the log:\n%s", got)
155+ }
156+ }
157+ if got := logOf(rec.step(t, "r")); !strings.Contains(got, "leak ***") {
158+ t.Errorf("want the token masked in\n%s", got)
159+ }
160+}
@@ -0,0 +1,160 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+ "testing"
8+)
9+
10+const (
11+ idTokenTestURL = "http://ermite.example:8088/-/oidc/token?api-version=2.0"
12+ idTokenTestToken = "ermiteoidc_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
13+)
14+
15+func withIDToken(c *Config) {
16+ c.IDTokenURL, c.IDTokenRequestToken = idTokenTestURL, idTokenTestToken
17+}
18+
19+const idTokenEcho = `echo "oidc url=[${ACTIONS_ID_TOKEN_REQUEST_URL-unset}] token=[${ACTIONS_ID_TOKEN_REQUEST_TOKEN-unset}]"`
20+
21+const idTokenAction = "name: e\ndescription: d\nruns:\n using: node20\n main: main.sh\n"
22+
23+var idTokenWant = "oidc url=[" + idTokenTestURL + "] token=[***]"
24+
25+const idTokenUnset = "oidc url=[unset] token=[unset]"
26+
27+// ScriptHandler.cs:315-319: a run step gets ACTIONS_ID_TOKEN_REQUEST_URL and
28+// ACTIONS_ID_TOKEN_REQUEST_TOKEN in its process, the token masked. The env of
29+// the step (its header, the env context) does not show them.
30+func TestRunStepGetsIDTokenEnv(t *testing.T) {
31+ rec, res := actionRun(t, files{},
32+ " - name: r\n env:\n FOO: bar\n run: |\n "+idTokenEcho+"\n echo \"ctx=[${{ env.ACTIONS_ID_TOKEN_REQUEST_URL }}]\"\n", withIDToken)
33+ if res.Conclusion != ConclusionSuccess {
34+ t.Fatalf("%+v", res)
35+ }
36+ got := logOf(rec.step(t, "r"))
37+ if !strings.Contains(got, idTokenWant) || !strings.Contains(got, "ctx=[]") {
38+ t.Errorf("want %q and an empty env context in\n%s", idTokenWant, got)
39+ }
40+ if strings.Contains(got, idTokenTestToken) || strings.Contains(got, " ACTIONS_ID_TOKEN_REQUEST_URL:") {
41+ t.Errorf("the token or the variables are in the log:\n%s", got)
42+ }
43+}
44+
45+// NodeScriptActionHandler.cs:66-70: a node action gets them too, without a
46+// cache service, and they are not in the env block of its header.
47+func TestNodeActionGetsIDTokenEnv(t *testing.T) {
48+ rec, res := actionRun(t, files{".github/actions/e/action.yml": idTokenAction, ".github/actions/e/main.sh": idTokenEcho + "\n"},
49+ " - uses: ./.github/actions/e\n", withIDToken)
50+ if res.Conclusion != ConclusionSuccess {
51+ t.Fatalf("%+v", res)
52+ }
53+ got := logOf(rec.step(t, "Run ./.github/actions/e"))
54+ if !strings.Contains(got, idTokenWant) {
55+ t.Errorf("want %q in\n%s", idTokenWant, got)
56+ }
57+ if strings.Contains(got, idTokenTestToken) || strings.Contains(got, " ACTIONS_ID_TOKEN_REQUEST_URL:") {
58+ t.Errorf("the token or the variables are in the log:\n%s", got)
59+ }
60+}
61+
62+// A composite action sets nothing itself; its steps get the variables from
63+// their own handlers, as on GitHub.
64+func TestCompositeStepsGetIDTokenEnv(t *testing.T) {
65+ composite := "name: c\ndescription: d\nruns:\n using: composite\n steps:\n - shell: bash\n run: '" + idTokenEcho + "'\n"
66+ rec, res := actionRun(t, files{".github/actions/c/action.yml": composite}, " - uses: ./.github/actions/c\n", withIDToken)
67+ if res.Conclusion != ConclusionSuccess {
68+ t.Fatalf("%+v", res)
69+ }
70+ if got := logOf(rec.step(t, "Run ./.github/actions/c")); !strings.Contains(got, idTokenWant) || strings.Contains(got, idTokenTestToken) {
71+ t.Errorf("want %q in\n%s", idTokenWant, got)
72+ }
73+}
74+
75+// ContainerActionHandler.cs:232-236: a docker action gets them by name on the
76+// "docker run" command line, never by value.
77+func TestDockerActionGetsIDTokenEnv(t *testing.T) {
78+ d, lf := fakeDocker(t, 0)
79+ rec, res := actionRun(t, files{}, " - uses: docker://alpine:3.20\n", func(c *Config) {
80+ c.Actions.DockerPath = d
81+ withIDToken(c)
82+ })
83+ if res.Conclusion != ConclusionSuccess {
84+ t.Fatalf("%+v %v", res, rec.names())
85+ }
86+ cs := calls(t, lf)
87+ if len(cs) != 2 {
88+ t.Fatalf("calls %v", cs)
89+ }
90+ args := "\n" + strings.Join(cs[1], "\n") + "\n"
91+ for _, n := range []string{"ACTIONS_ID_TOKEN_REQUEST_URL", "ACTIONS_ID_TOKEN_REQUEST_TOKEN"} {
92+ if !strings.Contains(args, "\n-e\n"+n+"\n") {
93+ t.Errorf("docker run lacks -e %s:\n%s", n, args)
94+ }
95+ }
96+ if strings.Contains(args, idTokenTestToken) {
97+ t.Errorf("the token value is on the command line:\n%s", args)
98+ }
99+}
100+
101+// A run step in the job container gets them through "docker exec -e NAME".
102+func TestRunStepInContainerGetsIDTokenEnv(t *testing.T) {
103+ f := newFakeCtr(t)
104+ rec, res := ctrRun(t, f, " container: ubuntu:24.04\n", " - name: s\n run: echo hi\n", withIDToken)
105+ if res.Conclusion != ConclusionSuccess {
106+ t.Fatalf("%+v %v", res, rec.names())
107+ }
108+ ex := f.execCalls(t)
109+ if len(ex) != 1 {
110+ t.Fatalf("exec calls %v", ex)
111+ }
112+ c := "\n" + strings.Join(ex[0], "\n") + "\n"
113+ for _, n := range []string{"ACTIONS_ID_TOKEN_REQUEST_URL", "ACTIONS_ID_TOKEN_REQUEST_TOKEN"} {
114+ if !strings.Contains(c, "\n-e\n"+n+"\n") {
115+ t.Errorf("exec lacks -e %s:\n%s", n, c)
116+ }
117+ }
118+ if strings.Contains(c, idTokenTestToken) {
119+ t.Errorf("the token value is on the command line:\n%s", c)
120+ }
121+}
122+
123+// Nothing is set unless the job has both the URL and the token.
124+func TestIDTokenEnvNeedsBoth(t *testing.T) {
125+ for name, tweak := range map[string]func(*Config){
126+ "none": nil,
127+ "url only": func(c *Config) { c.IDTokenURL = idTokenTestURL },
128+ "token only": func(c *Config) { c.IDTokenRequestToken = idTokenTestToken },
129+ } {
130+ t.Run(name, func(t *testing.T) {
131+ rec, res := actionRun(t, files{".github/actions/e/action.yml": idTokenAction, ".github/actions/e/main.sh": idTokenEcho + "\n"},
132+ " - name: r\n run: '"+idTokenEcho+"'\n - uses: ./.github/actions/e\n", tweak)
133+ if res.Conclusion != ConclusionSuccess {
134+ t.Fatalf("%+v", res)
135+ }
136+ for _, s := range []string{"r", "Run ./.github/actions/e"} {
137+ if got := logOf(rec.step(t, s)); !strings.Contains(got, idTokenUnset) {
138+ t.Errorf("%s: want %q in\n%s", s, idTokenUnset, got)
139+ }
140+ }
141+ })
142+ }
143+}
144+
145+// The token is in the masker before the first step: a step that prints it,
146+// even by another name, shows ***.
147+func TestIDTokenIsMasked(t *testing.T) {
148+ rec, res := actionRun(t, files{}, " - name: r\n run: echo \"leak "+idTokenTestToken+"\"\n", withIDToken)
149+ if res.Conclusion != ConclusionSuccess {
150+ t.Fatalf("%+v", res)
151+ }
152+ for _, s := range rec.steps {
153+ if got := logOf(s); strings.Contains(got, idTokenTestToken) {
154+ t.Errorf("the token is in the log:\n%s", got)
155+ }
156+ }
157+ if got := logOf(rec.step(t, "r")); !strings.Contains(got, "leak ***") {
158+ t.Errorf("want the token masked in\n%s", got)
159+ }
160+}
M internal/executor/job.go +7 -0
@@ -107,6 +107,12 @@ type Config struct {
107107 // job" prints as they are. Nil means the server sent none: they are then
108108 // worked out from the workflow. An empty, non-nil map is "none at all".
109109 TokenPermissions map[string]string
110+ // IDTokenURL and IDTokenRequestToken are Job.id_token_url (its origin
111+ // already rewritten for the sandbox) and Job.id_token_request_token:
112+ // ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN of the run
113+ // steps, node actions and docker actions (see addIDTokenEnv). Nothing is set
114+ // unless both are. The token is masked before anything is reported.
115+ IDTokenURL, IDTokenRequestToken string
110116 // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG).
111117 Debug bool
112118 // Actions configures the actions of the job (resolution, downloads, Node.js,
@@ -258,6 +264,7 @@ func Run(cancel *Canceller, cfg Config) Result {
258264 // values), so a one-character secret is masked too.
259265 j.masker.Store(newMasker())
260266 registerSecrets(j.masker.Load(), cfg.Secrets)
267+ j.masker.Load().add(cfg.IDTokenRequestToken)
261268 j.cfg.Reporter = &maskReporter{j: j, next: cfg.Reporter}
262269 defer func() {
263270 if j.fc != nil {
@@ -107,6 +107,12 @@ type Config struct {
107 // job" prints as they are. Nil means the server sent none: they are then107 // job" prints as they are. Nil means the server sent none: they are then
108 // worked out from the workflow. An empty, non-nil map is "none at all".108 // worked out from the workflow. An empty, non-nil map is "none at all".
109 TokenPermissions map[string]string109 TokenPermissions map[string]string
110+ // IDTokenURL and IDTokenRequestToken are Job.id_token_url (its origin
111+ // already rewritten for the sandbox) and Job.id_token_request_token:
112+ // ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN of the run
113+ // steps, node actions and docker actions (see addIDTokenEnv). Nothing is set
114+ // unless both are. The token is masked before anything is reported.
115+ IDTokenURL, IDTokenRequestToken string
110 // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG).116 // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG).
111 Debug bool117 Debug bool
112 // Actions configures the actions of the job (resolution, downloads, Node.js,118 // Actions configures the actions of the job (resolution, downloads, Node.js,
@@ -258,6 +264,7 @@ func Run(cancel *Canceller, cfg Config) Result {
258 // values), so a one-character secret is masked too.264 // values), so a one-character secret is masked too.
259 j.masker.Store(newMasker())265 j.masker.Store(newMasker())
260 registerSecrets(j.masker.Load(), cfg.Secrets)266 registerSecrets(j.masker.Load(), cfg.Secrets)
267+ j.masker.Load().add(cfg.IDTokenRequestToken)
261 j.cfg.Reporter = &maskReporter{j: j, next: cfg.Reporter}268 j.cfg.Reporter = &maskReporter{j: j, next: cfg.Reporter}
262 defer func() {269 defer func() {
263 if j.fc != nil {270 if j.fc != nil {
M internal/guest/jobexec.go +1 -0
@@ -265,6 +265,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
265265 Secrets: vals.secrets, Vars: vals.vars, SecretsWithheld: spec.GetSecretsWithheld(), TokenPermissions: tokenPermissions(spec),
266266 SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(),
267267 TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay,
268+ IDTokenURL: spec.GetIdTokenUrl(), IDTokenRequestToken: spec.GetIdTokenRequestToken(),
268269 })
269270 if result.Conclusion == executor.ConclusionSuccess {
270271 return 0
@@ -265,6 +265,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
265 Secrets: vals.secrets, Vars: vals.vars, SecretsWithheld: spec.GetSecretsWithheld(), TokenPermissions: tokenPermissions(spec),265 Secrets: vals.secrets, Vars: vals.vars, SecretsWithheld: spec.GetSecretsWithheld(), TokenPermissions: tokenPermissions(spec),
266 SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(),266 SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(),
267 TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay,267 TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay,
268+ IDTokenURL: spec.GetIdTokenUrl(), IDTokenRequestToken: spec.GetIdTokenRequestToken(),
268 })269 })
269 if result.Conclusion == executor.ConclusionSuccess {270 if result.Conclusion == executor.ConclusionSuccess {
270 return 0271 return 0
M internal/guest/jobexec_test.go +23 -0
@@ -414,3 +414,26 @@ func TestTokenPermissionsNilVersusEmpty(t *testing.T) {
414414 }
415415 }
416416 }
417+
418+// JobSpec.id_token_url and id_token_request_token reach the run steps as
419+// ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN, the token
420+// masked in every log line.
421+func TestExecJobGetsIDTokenRequest(t *testing.T) {
422+ requireBash(t)
423+ const url, token = "http://ermite.example:8088/-/oidc/token?api-version=2.0", "ermiteoidc_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
424+ yaml := "on: push\njobs:\n j:\n runs-on: x\n steps:\n - run: echo \"oidc [$ACTIONS_ID_TOKEN_REQUEST_URL] [$ACTIONS_ID_TOKEN_REQUEST_TOKEN]\"\n"
425+ _, logs, res, _ := runJobSessionSpec(t, yaml, "", nil, func(s *execpb.JobSpec) { s.IdTokenUrl, s.IdTokenRequestToken = url, token })
426+ if res == nil || res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS {
427+ t.Fatalf("%+v", res)
428+ }
429+ found := false
430+ for _, l := range logs {
431+ if strings.Contains(l.Text, token) {
432+ t.Errorf("the token is in the log: %q", l.Text)
433+ }
434+ found = found || l.Text == "oidc ["+url+"] [***]"
435+ }
436+ if !found {
437+ t.Error("the step did not print the variables")
438+ }
439+}
@@ -414,3 +414,26 @@ func TestTokenPermissionsNilVersusEmpty(t *testing.T) {
414 }414 }
415 }415 }
416 }416 }
417+
418+// JobSpec.id_token_url and id_token_request_token reach the run steps as
419+// ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN, the token
420+// masked in every log line.
421+func TestExecJobGetsIDTokenRequest(t *testing.T) {
422+ requireBash(t)
423+ const url, token = "http://ermite.example:8088/-/oidc/token?api-version=2.0", "ermiteoidc_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
424+ yaml := "on: push\njobs:\n j:\n runs-on: x\n steps:\n - run: echo \"oidc [$ACTIONS_ID_TOKEN_REQUEST_URL] [$ACTIONS_ID_TOKEN_REQUEST_TOKEN]\"\n"
425+ _, logs, res, _ := runJobSessionSpec(t, yaml, "", nil, func(s *execpb.JobSpec) { s.IdTokenUrl, s.IdTokenRequestToken = url, token })
426+ if res == nil || res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS {
427+ t.Fatalf("%+v", res)
428+ }
429+ found := false
430+ for _, l := range logs {
431+ if strings.Contains(l.Text, token) {
432+ t.Errorf("the token is in the log: %q", l.Text)
433+ }
434+ found = found || l.Text == "oidc ["+url+"] [***]"
435+ }
436+ if !found {
437+ t.Error("the step did not print the variables")
438+ }
439+}
M internal/host/guestforge.go +21 -7
@@ -81,16 +81,14 @@ func RewriteServerURLs(contexts, target string) (string, error) {
8181 continue
8282 }
8383 s, ok := str(m.val)
84- if !ok || originOf(s) != public {
84+ if !ok {
8585 continue
8686 }
87- rest := s[strings.Index(s, "://")+3:]
88- if k := strings.IndexAny(rest, "/?#"); k >= 0 {
89- rest = rest[k:]
90- } else {
91- rest = ""
87+ u, ok := rewriteOrigin(s, public, target)
88+ if !ok {
89+ continue
9290 }
93- nv, err := marshalNoEscape(target + rest)
91+ nv, err := marshalNoEscape(u)
9492 if err != nil {
9593 return "", err
9694 }
@@ -110,6 +108,22 @@ func RewriteServerURLs(contexts, target string) (string, error) {
110108 return string(out), nil
111109 }
112110
111+// rewriteOrigin returns raw with its origin replaced by target and true when
112+// the origin of raw is public (as originOf spells it); the path, the query and
113+// the fragment are kept. Anything else is returned as it is, with false.
114+func rewriteOrigin(raw, public, target string) (string, bool) {
115+ if public == "" || target == "" || originOf(raw) != public {
116+ return raw, false
117+ }
118+ rest := raw[strings.Index(raw, "://")+3:]
119+ if k := strings.IndexAny(rest, "/?#"); k >= 0 {
120+ rest = rest[k:]
121+ } else {
122+ rest = ""
123+ }
124+ return target + rest, true
125+}
126+
113127 // originOf returns "scheme://host[:port]" of an http(s) URL, lower-cased and
114128 // without the default port, an IPv6 host in brackets, or "" for anything else
115129 // (including URLs with credentials, which are never rewritten). It is
@@ -81,16 +81,14 @@ func RewriteServerURLs(contexts, target string) (string, error) {
81 continue81 continue
82 }82 }
83 s, ok := str(m.val)83 s, ok := str(m.val)
84- if !ok || originOf(s) != public {84+ if !ok {
85 continue85 continue
86 }86 }
87- rest := s[strings.Index(s, "://")+3:]87+ u, ok := rewriteOrigin(s, public, target)
88- if k := strings.IndexAny(rest, "/?#"); k >= 0 {88+ if !ok {
89- rest = rest[k:]89+ continue
90- } else {
91- rest = ""
92 }90 }
93- nv, err := marshalNoEscape(target + rest)91+ nv, err := marshalNoEscape(u)
94 if err != nil {92 if err != nil {
95 return "", err93 return "", err
96 }94 }
@@ -110,6 +108,22 @@ func RewriteServerURLs(contexts, target string) (string, error) {
110 return string(out), nil108 return string(out), nil
111 }109 }
112 110
111+// rewriteOrigin returns raw with its origin replaced by target and true when
112+// the origin of raw is public (as originOf spells it); the path, the query and
113+// the fragment are kept. Anything else is returned as it is, with false.
114+func rewriteOrigin(raw, public, target string) (string, bool) {
115+ if public == "" || target == "" || originOf(raw) != public {
116+ return raw, false
117+ }
118+ rest := raw[strings.Index(raw, "://")+3:]
119+ if k := strings.IndexAny(rest, "/?#"); k >= 0 {
120+ rest = rest[k:]
121+ } else {
122+ rest = ""
123+ }
124+ return target + rest, true
125+}
126+
113 // originOf returns "scheme://host[:port]" of an http(s) URL, lower-cased and127 // originOf returns "scheme://host[:port]" of an http(s) URL, lower-cased and
114 // without the default port, an IPv6 host in brackets, or "" for anything else128 // without the default port, an IPv6 host in brackets, or "" for anything else
115 // (including URLs with credentials, which are never rewritten). It is129 // (including URLs with credentials, which are never rewritten). It is
A internal/host/idtoken_test.go +71 -0
@@ -0,0 +1,71 @@
1+// SPDX-License-Identifier: MIT
2+
3+package host
4+
5+import (
6+ "testing"
7+
8+ ermite "bnema.dev/ermite-api/gen/ermite"
9+
10+ "bnema.dev/ermite-runner/internal/execstream"
11+ "bnema.dev/ermite-runner/internal/execstream/execpb"
12+)
13+
14+const (
15+ oidcPublicURL = "https://ermite.bnema.dev/-/oidc/token?api-version=2.0"
16+ oidcToken = "ermiteoidc_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
17+)
18+
19+// The executor gets Job.id_token_url, its origin rewritten to the guest forge
20+// like github.server_url (path and query kept), and the token, only when the
21+// server sent both.
22+func TestIDTokenRequestReachesTheExecutor(t *testing.T) {
23+ for _, tc := range []struct {
24+ name, url, token, guest string
25+ wantURL, wantToken string
26+ }{
27+ {"guest forge", oidcPublicURL, oidcToken, "http://ermite.bnema.dev:8088", "http://ermite.bnema.dev:8088/-/oidc/token?api-version=2.0", oidcToken},
28+ {"no guest forge", oidcPublicURL, oidcToken, "", oidcPublicURL, oidcToken},
29+ {"other origin", "https://other.example/-/oidc/token?api-version=2.0", oidcToken, "http://ermite.bnema.dev:8088", "https://other.example/-/oidc/token?api-version=2.0", oidcToken},
30+ {"url only", oidcPublicURL, "", "http://ermite.bnema.dev:8088", "", ""},
31+ {"token only", "", oidcToken, "http://ermite.bnema.dev:8088", "", ""},
32+ {"none", "", "", "", "", ""},
33+ } {
34+ t.Run(tc.name, func(t *testing.T) {
35+ h := newHarness(t, simpleScript("x"))
36+ job := testJob("j1")
37+ job.Contexts = publicContexts
38+ job.IdTokenUrl, job.IdTokenRequestToken = tc.url, tc.token
39+ h.srv.jobs = []*ermite.Job{job}
40+ h.runner.Cfg.GuestForgeOrigin = tc.guest
41+ var spec *execpb.JobSpec
42+ inner := h.driver.script
43+ h.driver.script = func(t *testing.T, c *execstream.Conn, run *execpb.Run, cn <-chan *execpb.Cancel) {
44+ spec = run.Job
45+ inner(t, c, run, cn)
46+ }
47+ h.run(t)
48+ eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 })
49+ if spec == nil {
50+ t.Fatal("no Run")
51+ }
52+ if spec.GetIdTokenUrl() != tc.wantURL || spec.GetIdTokenRequestToken() != tc.wantToken {
53+ t.Errorf("got %q %q, want %q %q", spec.GetIdTokenUrl(), spec.GetIdTokenRequestToken(), tc.wantURL, tc.wantToken)
54+ }
55+ })
56+ }
57+}
58+
59+func TestRewriteOrigin(t *testing.T) {
60+ for _, tc := range []struct{ in, want string }{
61+ {"https://ermite.bnema.dev/-/oidc/token?api-version=2.0", "http://g:8088/-/oidc/token?api-version=2.0"},
62+ {"https://ermite.bnema.dev:443?a=b", "http://g:8088?a=b"},
63+ {"https://ermite.bnema.dev", "http://g:8088"},
64+ {"https://other.example/x", "https://other.example/x"},
65+ {"https://u@ermite.bnema.dev/x", "https://u@ermite.bnema.dev/x"},
66+ } {
67+ if got, _ := rewriteOrigin(tc.in, "https://ermite.bnema.dev", "http://g:8088"); got != tc.want {
68+ t.Errorf("rewriteOrigin(%q) = %q, want %q", tc.in, got, tc.want)
69+ }
70+ }
71+}
@@ -0,0 +1,71 @@
1+// SPDX-License-Identifier: MIT
2+
3+package host
4+
5+import (
6+ "testing"
7+
8+ ermite "bnema.dev/ermite-api/gen/ermite"
9+
10+ "bnema.dev/ermite-runner/internal/execstream"
11+ "bnema.dev/ermite-runner/internal/execstream/execpb"
12+)
13+
14+const (
15+ oidcPublicURL = "https://ermite.bnema.dev/-/oidc/token?api-version=2.0"
16+ oidcToken = "ermiteoidc_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
17+)
18+
19+// The executor gets Job.id_token_url, its origin rewritten to the guest forge
20+// like github.server_url (path and query kept), and the token, only when the
21+// server sent both.
22+func TestIDTokenRequestReachesTheExecutor(t *testing.T) {
23+ for _, tc := range []struct {
24+ name, url, token, guest string
25+ wantURL, wantToken string
26+ }{
27+ {"guest forge", oidcPublicURL, oidcToken, "http://ermite.bnema.dev:8088", "http://ermite.bnema.dev:8088/-/oidc/token?api-version=2.0", oidcToken},
28+ {"no guest forge", oidcPublicURL, oidcToken, "", oidcPublicURL, oidcToken},
29+ {"other origin", "https://other.example/-/oidc/token?api-version=2.0", oidcToken, "http://ermite.bnema.dev:8088", "https://other.example/-/oidc/token?api-version=2.0", oidcToken},
30+ {"url only", oidcPublicURL, "", "http://ermite.bnema.dev:8088", "", ""},
31+ {"token only", "", oidcToken, "http://ermite.bnema.dev:8088", "", ""},
32+ {"none", "", "", "", "", ""},
33+ } {
34+ t.Run(tc.name, func(t *testing.T) {
35+ h := newHarness(t, simpleScript("x"))
36+ job := testJob("j1")
37+ job.Contexts = publicContexts
38+ job.IdTokenUrl, job.IdTokenRequestToken = tc.url, tc.token
39+ h.srv.jobs = []*ermite.Job{job}
40+ h.runner.Cfg.GuestForgeOrigin = tc.guest
41+ var spec *execpb.JobSpec
42+ inner := h.driver.script
43+ h.driver.script = func(t *testing.T, c *execstream.Conn, run *execpb.Run, cn <-chan *execpb.Cancel) {
44+ spec = run.Job
45+ inner(t, c, run, cn)
46+ }
47+ h.run(t)
48+ eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 })
49+ if spec == nil {
50+ t.Fatal("no Run")
51+ }
52+ if spec.GetIdTokenUrl() != tc.wantURL || spec.GetIdTokenRequestToken() != tc.wantToken {
53+ t.Errorf("got %q %q, want %q %q", spec.GetIdTokenUrl(), spec.GetIdTokenRequestToken(), tc.wantURL, tc.wantToken)
54+ }
55+ })
56+ }
57+}
58+
59+func TestRewriteOrigin(t *testing.T) {
60+ for _, tc := range []struct{ in, want string }{
61+ {"https://ermite.bnema.dev/-/oidc/token?api-version=2.0", "http://g:8088/-/oidc/token?api-version=2.0"},
62+ {"https://ermite.bnema.dev:443?a=b", "http://g:8088?a=b"},
63+ {"https://ermite.bnema.dev", "http://g:8088"},
64+ {"https://other.example/x", "https://other.example/x"},
65+ {"https://u@ermite.bnema.dev/x", "https://u@ermite.bnema.dev/x"},
66+ } {
67+ if got, _ := rewriteOrigin(tc.in, "https://ermite.bnema.dev", "http://g:8088"); got != tc.want {
68+ t.Errorf("rewriteOrigin(%q) = %q, want %q", tc.in, got, tc.want)
69+ }
70+ }
71+}
M internal/host/job.go +17 -0
@@ -700,6 +700,7 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) {
700700 // only when the server has ci.secrets.
701701 SecretsWithheld: j.features.secrets && !j.job.Trusted,
702702 }}
703+ run.Job.IdTokenUrl, run.Job.IdTokenRequestToken = j.idTokenRequest()
703704 if p := j.job.Permissions; p != nil && j.features.secrets {
704705 run.Job.TokenPermissions = &execpb.TokenPermissions{Levels: p.GetLevels()}
705706 }
@@ -715,6 +716,22 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) {
715716 return exit, err
716717 }
717718
719+// idTokenRequest returns Job.id_token_url, its origin rewritten to the guest
720+// forge as for github.server_url (RewriteServerURLs), and
721+// Job.id_token_request_token: the ACTIONS_ID_TOKEN_REQUEST_URL and
722+// ACTIONS_ID_TOKEN_REQUEST_TOKEN of the steps. Both are empty unless the
723+// server sent both. The token is never logged.
724+func (j *jobRun) idTokenRequest() (url, token string) {
725+ url, token = j.job.GetIdTokenUrl(), j.job.GetIdTokenRequestToken()
726+ if url == "" || token == "" {
727+ return "", ""
728+ }
729+ if j.guestForge != "" {
730+ url, _ = rewriteOrigin(url, j.publicOrigin, j.guestForge)
731+ }
732+ return url, token
733+}
734+
718735 // outcomeOf decides how the job ended from what the executor reported and what
719736 // the host asked of it.
720737 func (j *jobRun) outcomeOf(ctx context.Context, exit *execpb.Exit, driveErr error) outcome {
@@ -700,6 +700,7 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) {
700 // only when the server has ci.secrets.700 // only when the server has ci.secrets.
701 SecretsWithheld: j.features.secrets && !j.job.Trusted,701 SecretsWithheld: j.features.secrets && !j.job.Trusted,
702 }}702 }}
703+ run.Job.IdTokenUrl, run.Job.IdTokenRequestToken = j.idTokenRequest()
703 if p := j.job.Permissions; p != nil && j.features.secrets {704 if p := j.job.Permissions; p != nil && j.features.secrets {
704 run.Job.TokenPermissions = &execpb.TokenPermissions{Levels: p.GetLevels()}705 run.Job.TokenPermissions = &execpb.TokenPermissions{Levels: p.GetLevels()}
705 }706 }
@@ -715,6 +716,22 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) {
715 return exit, err716 return exit, err
716 }717 }
717 718
719+// idTokenRequest returns Job.id_token_url, its origin rewritten to the guest
720+// forge as for github.server_url (RewriteServerURLs), and
721+// Job.id_token_request_token: the ACTIONS_ID_TOKEN_REQUEST_URL and
722+// ACTIONS_ID_TOKEN_REQUEST_TOKEN of the steps. Both are empty unless the
723+// server sent both. The token is never logged.
724+func (j *jobRun) idTokenRequest() (url, token string) {
725+ url, token = j.job.GetIdTokenUrl(), j.job.GetIdTokenRequestToken()
726+ if url == "" || token == "" {
727+ return "", ""
728+ }
729+ if j.guestForge != "" {
730+ url, _ = rewriteOrigin(url, j.publicOrigin, j.guestForge)
731+ }
732+ return url, token
733+}
734+
718 // outcomeOf decides how the job ended from what the executor reported and what735 // outcomeOf decides how the job ended from what the executor reported and what
719 // the host asked of it.736 // the host asked of it.
720 func (j *jobRun) outcomeOf(ctx context.Context, exit *execpb.Exit, driveErr error) outcome {737 func (j *jobRun) outcomeOf(ctx context.Context, exit *execpb.Exit, driveErr error) outcome {

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