Skip to content

Commit 3ef4597

Browse files
committed
feat: add native CLI sign-in sessions
1 parent 433d7ad commit 3ef4597

11 files changed

Lines changed: 725 additions & 2 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
using System.Diagnostics;
2+
using ManagedCode.CodexSharpSDK.Client;
3+
using ManagedCode.CodexSharpSDK.Configuration;
4+
using ManagedCode.CodexSharpSDK.Models;
5+
6+
namespace ManagedCode.CodexSharpSDK.Tests.Integration;
7+
8+
[NotInParallel(CliProcessConstraint)]
9+
public class CliLoginTests
10+
{
11+
private const string CliProcessConstraint = "CliProcess";
12+
private const string RequiresCodexAuthProperty = "RequiresCodexAuth";
13+
private const string GuidFormat = "N";
14+
private const string SolutionFile = "ManagedCode.CodexSharpSDK.slnx";
15+
private const string TestsDirectory = "tests";
16+
private const string SandboxDirectory = ".sandbox";
17+
private const string SandboxPrefix = "CliLogin-";
18+
private const string PathVariable = "PATH";
19+
private const string HomeVariable = "HOME";
20+
private const string UserProfileVariable = "USERPROFILE";
21+
private const string ProviderHomeVariable = "CODEX_HOME";
22+
private const string ConfigHomeVariable = "XDG_CONFIG_HOME";
23+
private const string AppDataVariable = "APPDATA";
24+
private const string LocalAppDataVariable = "LOCALAPPDATA";
25+
private const string SystemRootVariable = "SystemRoot";
26+
private const string CiVariable = "CI";
27+
private const string BrowserVariable = "BROWSER";
28+
private const string NoBrowserCommand = "false";
29+
private const string TrueValue = "true";
30+
private const string HttpsScheme = "https";
31+
private const string HelpFlag = "--help";
32+
private const string CredentialFile = "auth.json";
33+
private const string PlainCode = "invalid-code";
34+
private static readonly TimeSpan LoginTimeout = TimeSpan.FromSeconds(60);
35+
36+
[Test]
37+
public async Task BeginLogin_AlreadyCancelled_DoesNotWriteCredentials()
38+
{
39+
using var profile = new LoginProfile();
40+
using var client = new CodexClient(profile.CreateOptions());
41+
using var cancellation = new CancellationTokenSource();
42+
await cancellation.CancelAsync();
43+
await Assert.ThrowsAsync<OperationCanceledException>(async () => await client.BeginLoginAsync(cancellation.Token));
44+
await Assert.That(profile.HasCredentials).IsFalse();
45+
}
46+
47+
[Test]
48+
public async Task BeginLogin_NativeHelpExit_IsNotAuthenticationSuccess()
49+
{
50+
using var profile = new LoginProfile();
51+
var options = profile.CreateOptions();
52+
var launch = options.GetCliLaunchCommand();
53+
using var client = new CodexClient(options with
54+
{
55+
LaunchCommand = new CliLaunchCommand(launch.ExecutablePath, launch.PrefixArguments.Add(HelpFlag)),
56+
});
57+
using var deadline = new CancellationTokenSource(LoginTimeout);
58+
await Assert.ThrowsAsync<InvalidOperationException>(async () => await client.BeginLoginAsync(deadline.Token));
59+
await Assert.That(profile.HasCredentials).IsFalse();
60+
}
61+
62+
[Property(RequiresCodexAuthProperty, TrueValue)]
63+
[Test]
64+
public async Task BeginLogin_NativeInstructions_WaitCancellationAndSessionCancellationHaveDifferentEffects()
65+
{
66+
using var profile = new LoginProfile();
67+
using var client = new CodexClient(profile.CreateOptions());
68+
using var deadline = new CancellationTokenSource(LoginTimeout);
69+
await using var login = await client.BeginLoginAsync(deadline.Token);
70+
var processId = login.ProcessId;
71+
var instructions = login.Instructions;
72+
await Assert.That(instructions).IsNotNull();
73+
await Assert.That(instructions!.LoginUrl.Scheme).IsEqualTo(HttpsScheme);
74+
await Assert.That(instructions.RequiresAuthorizationCode).IsFalse();
75+
await Assert.That(string.IsNullOrWhiteSpace(instructions.UserCode)).IsFalse();
76+
using var observation = new CancellationTokenSource();
77+
await observation.CancelAsync();
78+
await Assert.ThrowsAsync<OperationCanceledException>(async () => await login.WaitForCompletionAsync(observation.Token));
79+
using (var native = Process.GetProcessById(processId))
80+
{
81+
await Assert.That(native.HasExited).IsFalse();
82+
}
83+
await Assert.ThrowsAsync<NotSupportedException>(async () => await login.SubmitAuthorizationCodeAsync(PlainCode));
84+
await login.CancelAsync(deadline.Token);
85+
await Assert.ThrowsAsync<OperationCanceledException>(async () => await login.WaitForCompletionAsync());
86+
await Assert.That(IsRunning(processId)).IsFalse();
87+
await Assert.That(profile.HasCredentials).IsFalse();
88+
}
89+
90+
private static bool IsRunning(int processId)
91+
{
92+
try
93+
{
94+
using var process = Process.GetProcessById(processId);
95+
return !process.HasExited;
96+
}
97+
catch (ArgumentException)
98+
{
99+
return false;
100+
}
101+
}
102+
103+
private sealed class LoginProfile : IDisposable
104+
{
105+
private readonly string _root;
106+
private readonly CliLaunchCommand _launch;
107+
108+
public LoginProfile()
109+
{
110+
var current = new DirectoryInfo(AppContext.BaseDirectory);
111+
while (current is not null && !File.Exists(Path.Combine(current.FullName, SolutionFile)))
112+
{
113+
current = current.Parent;
114+
}
115+
_root = Path.Combine(current!.FullName, TestsDirectory, SandboxDirectory,
116+
string.Concat(SandboxPrefix, Guid.NewGuid().ToString(GuidFormat)));
117+
Directory.CreateDirectory(_root);
118+
_launch = new CodexOptions().GetCliLaunchCommand();
119+
}
120+
121+
public bool HasCredentials => Directory.GetFiles(_root, CredentialFile, SearchOption.AllDirectories).Length != 0;
122+
123+
public CodexOptions CreateOptions()
124+
{
125+
var variables = new Dictionary<string, string>
126+
{
127+
[PathVariable] = Environment.GetEnvironmentVariable(PathVariable)!,
128+
[HomeVariable] = _root,
129+
[UserProfileVariable] = _root,
130+
[ProviderHomeVariable] = _root,
131+
[ConfigHomeVariable] = _root,
132+
[AppDataVariable] = _root,
133+
[LocalAppDataVariable] = _root,
134+
[CiVariable] = TrueValue,
135+
[BrowserVariable] = NoBrowserCommand,
136+
};
137+
if (Environment.GetEnvironmentVariable(SystemRootVariable) is { } systemRoot)
138+
{
139+
variables[SystemRootVariable] = systemRoot;
140+
}
141+
return new CodexOptions { LaunchCommand = _launch, EnvironmentVariables = variables, InheritEnvironmentVariables = false };
142+
}
143+
144+
public void Dispose() => Directory.Delete(_root, recursive: true);
145+
}
146+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
using System.Text.Json.Nodes;
2+
using ManagedCode.CodexSharpSDK.Internal;
3+
using ManagedCode.CodexSharpSDK.Models;
4+
5+
namespace ManagedCode.CodexSharpSDK.Client;
6+
7+
public sealed partial class CliLoginSession
8+
{
9+
private const string AppServerCommand = "app-server";
10+
private const string ConfigFlag = "-c";
11+
private const string ClientInfo = "clientInfo";
12+
private const string ClientName = "name";
13+
private const string ClientTitle = "title";
14+
private const string ClientVersion = "version";
15+
private const string SdkClientName = "managedcode_codex_sdk";
16+
private const string SdkClientTitle = "ManagedCode Codex SDK";
17+
private const string InitializedMethod = "initialized";
18+
private const string LoginStartMethod = "account/login/start";
19+
private const string LoginCompletedMethod = "account/login/completed";
20+
private const string LoginType = "type";
21+
private const string DeviceCodeType = "chatgptDeviceCode";
22+
private const string LoginId = "loginId";
23+
private const string VerificationUrl = "verificationUrl";
24+
private const string UserCode = "userCode";
25+
private const string Success = "success";
26+
private const string InvalidInstructionsMessage = "Codex did not return complete device-code sign-in instructions.";
27+
private string? _loginId;
28+
29+
internal static Task<CliLoginSession> StartAsync(CliLaunchCommand launchCommand,
30+
IReadOnlyDictionary<string, string> environment, JsonObject? config, TimeSpan terminationTimeout,
31+
int maximumOutputCharacters, CancellationToken cancellationToken)
32+
{
33+
var arguments = new List<string> { AppServerCommand };
34+
if (config is not null)
35+
{
36+
foreach (var value in TomlConfigSerializer.Serialize(config))
37+
{
38+
arguments.Add(ConfigFlag);
39+
arguments.Add(value);
40+
}
41+
}
42+
return StartAsync(CreateStartInfo(launchCommand, environment, arguments.ToArray()), terminationTimeout,
43+
maximumOutputCharacters, cancellationToken);
44+
}
45+
46+
private Task InitializeNativeAsync() => SendRpcAsync(InitializeRequestId, InitializeMethod, new JsonObject
47+
{
48+
[ClientInfo] = new JsonObject
49+
{
50+
[ClientName] = SdkClientName,
51+
[ClientTitle] = SdkClientTitle,
52+
[ClientVersion] = typeof(CodexClient).Assembly.GetName().Version!.ToString(),
53+
},
54+
});
55+
56+
private async Task ProcessNativeLineAsync(bool standardError, string line)
57+
{
58+
if (standardError || !line.StartsWith('{'))
59+
{
60+
return;
61+
}
62+
var message = JsonNode.Parse(line)!.AsObject();
63+
if (message[RpcMethod] is { } method)
64+
{
65+
if (method.GetValue<string>() == LoginCompletedMethod && message[RpcParams] is JsonObject parameters &&
66+
parameters[LoginId]?.GetValue<string>() == _loginId)
67+
{
68+
if (parameters[Success]?.GetValue<bool>() != true)
69+
{
70+
throw new InvalidOperationException(parameters[RpcError]?.GetValue<string>() ?? NativeProtocolFailedMessage);
71+
}
72+
_authenticated = true;
73+
_process.StandardInput.Close();
74+
}
75+
return;
76+
}
77+
if (message[RpcId] is not { } id)
78+
{
79+
return;
80+
}
81+
if (message[RpcError] is { } error)
82+
{
83+
throw new InvalidOperationException(error[RpcMessage]?.GetValue<string>() ?? NativeProtocolFailedMessage);
84+
}
85+
if (id.GetValue<int>() == InitializeRequestId)
86+
{
87+
await WriteRpcAsync(new JsonObject { [RpcMethod] = InitializedMethod }).ConfigureAwait(false);
88+
await SendRpcAsync(LoginRequestId, LoginStartMethod, new JsonObject { [LoginType] = DeviceCodeType }).ConfigureAwait(false);
89+
}
90+
else if (id.GetValue<int>() == LoginRequestId)
91+
{
92+
var result = message[RpcResult];
93+
_loginId = result?[LoginId]?.GetValue<string>();
94+
var url = result?[VerificationUrl]?.GetValue<string>();
95+
var userCode = result?[UserCode]?.GetValue<string>();
96+
if (result?[LoginType]?.GetValue<string>() != DeviceCodeType || string.IsNullOrWhiteSpace(_loginId) ||
97+
string.IsNullOrWhiteSpace(userCode) || !Uri.TryCreate(url, UriKind.Absolute, out var verificationUrl))
98+
{
99+
throw new InvalidDataException(InvalidInstructionsMessage);
100+
}
101+
PublishInstructions(verificationUrl, userCode, false);
102+
}
103+
}
104+
105+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using System.Text.Json.Nodes;
2+
3+
namespace ManagedCode.CodexSharpSDK.Client;
4+
5+
public sealed partial class CliLoginSession
6+
{
7+
private const string RpcId = "id";
8+
private const string RpcMethod = "method";
9+
private const string RpcParams = "params";
10+
private const string RpcResult = "result";
11+
private const string RpcError = "error";
12+
private const string RpcMessage = "message";
13+
private const string RpcJsonVersion = "jsonrpc";
14+
private const string RpcVersion = "2.0";
15+
private const string InitializeMethod = "initialize";
16+
private const string NativeProtocolFailedMessage = "The CLI rejected the sign-in request.";
17+
private const string NativeSuccessMissingMessage = "The CLI exited without a successful sign-in result.";
18+
private const int InitializeRequestId = 0;
19+
private const int LoginRequestId = 1;
20+
21+
private Task SendRpcAsync(int id, string method, JsonObject parameters) => WriteRpcAsync(new JsonObject
22+
{
23+
[RpcJsonVersion] = RpcVersion,
24+
[RpcId] = id,
25+
[RpcMethod] = method,
26+
[RpcParams] = parameters,
27+
});
28+
29+
private async Task WriteRpcAsync(JsonObject message)
30+
{
31+
await _process.StandardInput.WriteLineAsync(message.ToJsonString().AsMemory(), _lifetime.Token).ConfigureAwait(false);
32+
await _process.StandardInput.FlushAsync(_lifetime.Token).ConfigureAwait(false);
33+
}
34+
35+
private void ValidateNativeExit()
36+
{
37+
if (_process.ExitCode != 0 || !_authenticated)
38+
{
39+
throw new InvalidOperationException(NativeSuccessMissingMessage);
40+
}
41+
}
42+
}

0 commit comments

Comments
 (0)