fix(worker): materialize env files 0600, re-materialize per run, delete on stop
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { assertContainedRealPath } from './path-containment';
|
||||
|
||||
export type MaterializedSecret = { name: string; value: string };
|
||||
|
||||
// Format matches the historical `NAME="value"` env-file layout the agent CLIs
|
||||
// source; values are JSON-quoted so newlines/quotes survive.
|
||||
export const formatMaterializedEnv = (secrets: MaterializedSecret[]) =>
|
||||
`${secrets
|
||||
.map((secret) => `${secret.name}=${JSON.stringify(secret.value)}`)
|
||||
.join('\n')}\n`;
|
||||
|
||||
// Resolves the contained env-file path inside the checkout and writes it 0600
|
||||
// so plaintext secrets never sit world/group-readable in the persistent home.
|
||||
export const writeMaterializedEnv = async (
|
||||
repoDir: string,
|
||||
envFilePath: string,
|
||||
secrets: MaterializedSecret[],
|
||||
): Promise<string> => {
|
||||
const envPath = await assertContainedRealPath(repoDir, envFilePath, {
|
||||
forWrite: true,
|
||||
});
|
||||
await mkdir(path.dirname(envPath), { recursive: true });
|
||||
await writeFile(envPath, formatMaterializedEnv(secrets), { mode: 0o600 });
|
||||
return envPath;
|
||||
};
|
||||
|
||||
// Removes a previously materialized env file; no-ops when the path is unset or
|
||||
// already gone so it is safe on every teardown path.
|
||||
export const removeMaterializedEnv = async (envPath: string | undefined) => {
|
||||
if (!envPath) return;
|
||||
await rm(envPath, { force: true });
|
||||
};
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from './git';
|
||||
import { getInstallationToken, openDraftPullRequest } from './github';
|
||||
import { createHeartbeatController } from './heartbeat-controller';
|
||||
import { removeMaterializedEnv, writeMaterializedEnv } from './materialize-env';
|
||||
import { assertContainedRealPath } from './path-containment';
|
||||
import { createRedactor, truncate } from './redact';
|
||||
import {
|
||||
@@ -50,6 +51,8 @@ type ActiveWorkspace = AdapterWorkspace & {
|
||||
cancelRequested?: boolean;
|
||||
resolveTurn?: () => void;
|
||||
lastRecordedDiffSignature?: string;
|
||||
// Absolute path of the last materialized env file so teardown can delete it.
|
||||
materializedEnvPath?: string;
|
||||
};
|
||||
|
||||
type FileTreeNode = {
|
||||
@@ -717,16 +720,11 @@ const recordChangedFiles = async (
|
||||
const materializeEnvFile = async (workspace: ActiveWorkspace) => {
|
||||
const { claim, repoDir } = workspace;
|
||||
if (!claim.job.materializeEnvFile || !claim.job.envFilePath) return;
|
||||
const envPath = await assertContainedRealPath(
|
||||
workspace.materializedEnvPath = await writeMaterializedEnv(
|
||||
repoDir,
|
||||
claim.job.envFilePath,
|
||||
{ forWrite: true },
|
||||
claim.secrets,
|
||||
);
|
||||
await mkdir(path.dirname(envPath), { recursive: true });
|
||||
const content = `${claim.secrets
|
||||
.map((secret) => `${secret.name}=${JSON.stringify(secret.value)}`)
|
||||
.join('\n')}\n`;
|
||||
await writeFile(envPath, content);
|
||||
await appendEvent(
|
||||
claim.job._id,
|
||||
'info',
|
||||
@@ -1194,6 +1192,9 @@ export const sendWorkspaceMessage = async (
|
||||
if (workspace.agentTurnActive) {
|
||||
throw new Error('Wait for the current agent turn to finish or abort it.');
|
||||
}
|
||||
// Re-materialize the env file fresh each turn so it is present for the
|
||||
// adapter and can be removed between turns / on teardown.
|
||||
await materializeEnvFile(workspace);
|
||||
if (options.recordUserMessage ?? true) {
|
||||
await appendMessage({
|
||||
jobId: claim.job._id,
|
||||
@@ -1376,6 +1377,8 @@ const teardownActiveWorkspace = async (args: {
|
||||
abortAgent: args.abortAgent
|
||||
? async () => await abortWorkspaceAgentForWorkspace(workspace)
|
||||
: undefined,
|
||||
removeMaterializedEnv: async () =>
|
||||
await removeMaterializedEnv(workspace.materializedEnvPath),
|
||||
markStopped: async () =>
|
||||
await markWorkspaceStopped(workspace.claim.job._id, args.workspaceStatus),
|
||||
release: () => workspace.boxHandle.release(),
|
||||
|
||||
@@ -5,6 +5,7 @@ export const teardownWorkspace = async (steps: {
|
||||
removeActive: TeardownStep;
|
||||
appendEvent?: TeardownStep;
|
||||
abortAgent?: TeardownStep;
|
||||
removeMaterializedEnv?: TeardownStep;
|
||||
markStopped?: TeardownStep;
|
||||
closeAgent?: TeardownStep;
|
||||
stopContainer?: TeardownStep;
|
||||
@@ -27,6 +28,7 @@ export const teardownWorkspace = async (steps: {
|
||||
await Promise.all(immediate);
|
||||
await call(steps.appendEvent);
|
||||
await call(steps.abortAgent);
|
||||
await call(steps.removeMaterializedEnv);
|
||||
await call(steps.markStopped);
|
||||
await call(steps.closeAgent);
|
||||
await call(steps.stopContainer);
|
||||
|
||||
Reference in New Issue
Block a user