Skip to content

Commit 3867f45

Browse files
committed
Introduce environment variable management in docker session window
1 parent 3a9fa25 commit 3867f45

11 files changed

Lines changed: 590 additions & 45 deletions

File tree

src/BE/tests/Chats.BE.UnitTest/CodeInterpreter/CodeInterpreterPlaceholderTest.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public sealed class CodeInterpreterPlaceholderTest
1616
[InlineData(100L * 1024 * 1024, "100MB")]
1717
public void FormatBytes_ShouldFormatCorrectly(long bytes, string expected)
1818
{
19-
string result = CodeInterpreterExecutor.FormatBytes(bytes);
19+
string result = BytesFormatter.Format(bytes);
2020
Assert.Equal(expected, result);
2121
}
2222

src/BE/web/Controllers/Chats/DockerSessions/ChatDockerSessionsController.cs

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,180 @@ public async Task<IActionResult> SaveTextFile(
449449
return Ok();
450450
}
451451

452+
private const string UserEnvFilePath = "/etc/profile.d/sdcb-chats-env.sh";
453+
454+
[HttpGet("{encryptedSessionId}/environment-variables")]
455+
public async Task<ActionResult<EnvironmentVariablesResponse>> GetEnvironmentVariables(
456+
string encryptedChatId,
457+
[Required] string encryptedSessionId,
458+
CancellationToken cancellationToken)
459+
{
460+
int chatId = _idEncryption.DecryptChatId(encryptedChatId);
461+
long sessionId = _idEncryption.DecryptAsInt64(encryptedSessionId, EncryptionPurpose.DockerSessionId);
462+
ChatDockerSession? session = await GetActiveSessionForChat(chatId, sessionId, cancellationToken);
463+
if (session == null) return NotFound();
464+
465+
string[] shellPrefix = ParseShellPrefixCsv(session.ShellPrefix);
466+
467+
// Get all environment variables via printenv
468+
CommandExitEvent allEnvResult = await _docker.ExecuteCommandAsync(
469+
session.ContainerId,
470+
shellPrefix,
471+
"printenv",
472+
_codePodConfig.WorkDir,
473+
timeoutSeconds: 30,
474+
cancellationToken);
475+
476+
Dictionary<string, string> allEnvVars = ParsePrintEnvOutput(allEnvResult.Stdout);
477+
478+
// Get user environment variables from the user env file
479+
Dictionary<string, string> userEnvVars = [];
480+
try
481+
{
482+
CommandExitEvent userEnvResult = await _docker.ExecuteCommandAsync(
483+
session.ContainerId,
484+
shellPrefix,
485+
$"cat {UserEnvFilePath} 2>/dev/null || true",
486+
_codePodConfig.WorkDir,
487+
timeoutSeconds: 30,
488+
cancellationToken);
489+
490+
userEnvVars = ParseUserEnvFile(userEnvResult.Stdout);
491+
}
492+
catch
493+
{
494+
// File may not exist, return empty user variables
495+
}
496+
497+
// Build system variables (exclude user variables)
498+
HashSet<string> userKeys = [.. userEnvVars.Keys];
499+
List<EnvironmentVariable> systemVariables = allEnvVars
500+
.Where(kv => !userKeys.Contains(kv.Key))
501+
.Select(kv => new EnvironmentVariable(kv.Key, kv.Value))
502+
.OrderBy(v => v.Key, StringComparer.OrdinalIgnoreCase)
503+
.ToList();
504+
505+
List<EnvironmentVariable> userVariables = userEnvVars
506+
.Select(kv => new EnvironmentVariable(kv.Key, kv.Value))
507+
.OrderBy(v => v.Key, StringComparer.OrdinalIgnoreCase)
508+
.ToList();
509+
510+
await TouchSession(session.Id, cancellationToken);
511+
return new EnvironmentVariablesResponse(systemVariables, userVariables);
512+
}
513+
514+
[HttpPut("{encryptedSessionId}/environment-variables")]
515+
public async Task<IActionResult> SaveUserEnvironmentVariables(
516+
string encryptedChatId,
517+
[Required] string encryptedSessionId,
518+
[FromBody] SaveUserEnvironmentVariablesRequest request,
519+
CancellationToken cancellationToken)
520+
{
521+
if (!ModelState.IsValid) return BadRequest(ModelState);
522+
523+
int chatId = _idEncryption.DecryptChatId(encryptedChatId);
524+
long sessionId = _idEncryption.DecryptAsInt64(encryptedSessionId, EncryptionPurpose.DockerSessionId);
525+
ChatDockerSession? session = await GetActiveSessionForChat(chatId, sessionId, cancellationToken);
526+
if (session == null) return NotFound();
527+
528+
if (_codePodConfig.IsWindowsContainer)
529+
{
530+
return BadRequest("Saving user environment variables is not supported on Windows containers.");
531+
}
532+
533+
// Validate variable names
534+
foreach (EnvironmentVariable v in request.Variables)
535+
{
536+
if (string.IsNullOrWhiteSpace(v.Key))
537+
{
538+
return BadRequest("Environment variable key cannot be empty.");
539+
}
540+
if (!IsValidEnvVarName(v.Key))
541+
{
542+
return BadRequest($"Invalid environment variable name: {v.Key}. Only alphanumeric characters and underscores are allowed, and it cannot start with a digit.");
543+
}
544+
}
545+
546+
// Build the shell script content (use LF line endings for Linux)
547+
StringBuilder sb = new();
548+
sb.Append("#!/bin/sh\n");
549+
sb.Append("# User environment variables managed by Sdcb Chats\n");
550+
sb.Append("# Do not edit this file manually\n");
551+
sb.Append('\n');
552+
foreach (EnvironmentVariable v in request.Variables)
553+
{
554+
// Escape single quotes in value
555+
string escapedValue = v.Value.Replace("'", "'\"'\"'");
556+
sb.Append($"export {v.Key}='{escapedValue}'\n");
557+
}
558+
559+
string content = sb.ToString();
560+
byte[] bytes = Encoding.UTF8.GetBytes(content);
561+
562+
await _docker.UploadFileAsync(session.ContainerId, UserEnvFilePath, bytes, cancellationToken);
563+
await TouchSession(session.Id, cancellationToken);
564+
return Ok();
565+
}
566+
567+
private static Dictionary<string, string> ParsePrintEnvOutput(string output)
568+
{
569+
Dictionary<string, string> result = [];
570+
foreach (string line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
571+
{
572+
int eqIndex = line.IndexOf('=');
573+
if (eqIndex > 0)
574+
{
575+
string key = line[..eqIndex].Trim();
576+
string value = line[(eqIndex + 1)..].TrimEnd('\r');
577+
result[key] = value;
578+
}
579+
}
580+
return result;
581+
}
582+
583+
private static Dictionary<string, string> ParseUserEnvFile(string content)
584+
{
585+
Dictionary<string, string> result = [];
586+
foreach (string line in content.Split('\n', StringSplitOptions.RemoveEmptyEntries))
587+
{
588+
string trimmed = line.Trim();
589+
if (trimmed.StartsWith("export ", StringComparison.Ordinal))
590+
{
591+
// export KEY='value' or export KEY="value" or export KEY=value
592+
string rest = trimmed["export ".Length..];
593+
int eqIndex = rest.IndexOf('=');
594+
if (eqIndex > 0)
595+
{
596+
string key = rest[..eqIndex].Trim();
597+
string rawValue = rest[(eqIndex + 1)..].Trim();
598+
string value = UnquoteValue(rawValue);
599+
result[key] = value;
600+
}
601+
}
602+
}
603+
return result;
604+
}
605+
606+
private static string UnquoteValue(string value)
607+
{
608+
if (value.Length >= 2)
609+
{
610+
if ((value.StartsWith('\'') && value.EndsWith('\'')) ||
611+
(value.StartsWith('"') && value.EndsWith('"')))
612+
{
613+
return value[1..^1].Replace("'\"'\"'", "'");
614+
}
615+
}
616+
return value;
617+
}
618+
619+
private static bool IsValidEnvVarName(string name)
620+
{
621+
if (string.IsNullOrEmpty(name)) return false;
622+
if (char.IsDigit(name[0])) return false;
623+
return name.All(c => char.IsLetterOrDigit(c) || c == '_');
624+
}
625+
452626
private async Task Yield(CommandStreamLine line, CancellationToken cancellationToken)
453627
{
454628
await Response.Body.WriteAsync(_dataU8, cancellationToken);
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Chats.BE.Controllers.Chats.DockerSessions.Dtos;
4+
5+
public sealed record EnvironmentVariablesResponse(
6+
[property: JsonPropertyName("systemVariables")] IReadOnlyList<EnvironmentVariable> SystemVariables,
7+
[property: JsonPropertyName("userVariables")] IReadOnlyList<EnvironmentVariable> UserVariables
8+
);
9+
10+
public sealed record EnvironmentVariable(
11+
[property: JsonPropertyName("key")] string Key,
12+
[property: JsonPropertyName("value")] string Value
13+
);
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using System.Text.Json.Serialization;
3+
4+
namespace Chats.BE.Controllers.Chats.DockerSessions.Dtos;
5+
6+
public sealed class SaveUserEnvironmentVariablesRequest
7+
{
8+
[JsonPropertyName("variables")]
9+
[Required]
10+
public IReadOnlyList<EnvironmentVariable> Variables { get; set; } = [];
11+
}

src/FE/apis/dockerSessionsApi.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
SaveTextFileRequest,
1515
DockerSessionDto,
1616
TextFileResponse,
17+
EnvironmentVariablesResponse,
18+
SaveUserEnvironmentVariablesRequest,
1719
} from '@/types/dockerSessions';
1820

1921
type FetchOptions = {
@@ -251,3 +253,26 @@ export const saveDockerTextFile = (
251253
{ body, suppressDefaultToast: true },
252254
);
253255
};
256+
257+
export const getDockerEnvironmentVariables = (
258+
chatId: string,
259+
encryptedSessionId: string,
260+
) => {
261+
const fetchService = createFetchClient();
262+
return fetchService.get<EnvironmentVariablesResponse>(
263+
`/api/chat/${chatId}/docker-sessions/${encodeURIComponent(encryptedSessionId)}/environment-variables`,
264+
{ suppressDefaultToast: true },
265+
);
266+
};
267+
268+
export const saveDockerUserEnvironmentVariables = (
269+
chatId: string,
270+
encryptedSessionId: string,
271+
body: SaveUserEnvironmentVariablesRequest,
272+
) => {
273+
const fetchService = createFetchClient();
274+
return fetchService.put<void>(
275+
`/api/chat/${chatId}/docker-sessions/${encodeURIComponent(encryptedSessionId)}/environment-variables`,
276+
{ body, suppressDefaultToast: true },
277+
);
278+
};

src/FE/components/ChatSessionManager/ChatSessionManagerWindow.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ export default function ChatSessionManagerWindow({
333333
activeTab === 'info' ? 'visible' : 'invisible pointer-events-none',
334334
)}
335335
>
336-
<SessionInfoCard session={selectedSession} />
336+
<SessionInfoCard chatId={chatId} session={selectedSession} />
337337
</div>
338338

339339
{/* Command tab - 保持挂载以保留状态 */}

0 commit comments

Comments
 (0)