Add explicit deletion controls for archived Codex cloud sessions

Open 💬 14 comments Opened May 26, 2026 by Aesthermortis
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

Problem

Codex cloud sessions appear to remain available as archived conversations long after they were used. This is a serious privacy and data-retention concern for a developer tool.

Archiving is not deletion. For coding workflows, archived sessions may contain sensitive project context, repository names, file paths, logs, diffs, prompts, implementation details, and occasionally accidental secrets. Hiding these sessions from the main UI does not satisfy the expected behavior of deleting developer work sessions.

I previously reported a related issue for local Codex state stored under ./codex, where chat/session data was not clearly removable. The same problem now appears to exist for Codex cloud sessions: there is no obvious, dedicated, auditable way to delete old Codex cloud task history separately from normal ChatGPT conversations.

Expected behavior

Codex should provide explicit deletion controls for cloud sessions:

  • Delete a single Codex cloud session permanently.
  • Delete all archived Codex cloud sessions.
  • Delete all Codex cloud task history independently from normal ChatGPT chats.
  • Clearly distinguish Archive from Delete in the Codex UI.
  • Show retention status after deletion, for example "scheduled for deletion".
  • Provide an option to disable persistent Codex cloud session history by default.
  • Provide a temporary/private Codex task mode where sessions are not retained beyond the minimum operational window.

Actual behavior

Old Codex cloud sessions are still visible as archived conversations. The current behavior makes it easy to assume that old sessions were removed or are no longer retained, when in practice they appear to remain associated with the account.

For developer tooling, this is a higher-risk default than normal chat history because Codex sessions can contain project-specific and security-sensitive information.

Why this matters

A coding assistant should treat cloud task history as sensitive developer data, not as ordinary chat clutter.

Archive is useful for organization, but it is not a privacy control. Users need a clear deletion path and a way to verify that old Codex cloud sessions are no longer retained in their account.

This is especially important because Codex can interact with private repositories, local development context, logs, stack traces, dependency information, and internal implementation details.

Requested fix

Please add dedicated Codex data controls for cloud sessions:

  1. A Codex-specific history/data section.
  2. Bulk deletion for archived Codex cloud sessions.
  3. Per-session deletion from the Codex cloud/task UI.
  4. A retention setting for Codex cloud tasks.
  5. Clear UI language that "Archive" means "hide", not "delete".
  6. Documentation explaining where Codex cloud sessions are stored, how long they are retained, and how users can permanently delete them.

Security/privacy impact

This is not only a UX issue. It affects user trust and data minimization for developer workflows. A user should not have to search through archived general chats to discover old Codex cloud sessions from months ago.

Codex should make deletion explicit, separate, and easy to audit.

View original on GitHub ↗

14 Comments

Aesthermortis · 3 months ago

Chats from a year ago!... They're still archived on Codex Cloud. I'm never using that thing again. Luckily, I only used it for about 10 chats. But it really bothers me that it's been there for a year. This is getting awkward.

github-actions[bot] contributor · 3 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #24417

Powered by Codex Action

kotoru-maker · 2 months ago

Claude Code already offered this long time ago and deletes the sessions within 24h. Codex will remain seriously flawed until it can offer the same service.

kotoru-maker · 2 months ago

It appears the only 'safe' way to have the cloud-side deleted is to delete the entire account.

basantmandal · 1 month ago

I skipped purchasing Codex because I couldn't find a way to delete cloud projects. That felt like a significant limitation and raised privacy and data-retention concerns.

Update: I found a workaround.

If you disconnect the Codex CLI from "Secure sign in with ChatGPT", all associated Codex Cloud projects disappear. However, they reappear if you sign in and use Codex again.

Screenshot - <img width="649" height="149" alt="Image" src="https://github.com/user-attachments/assets/7d909111-2fe8-4bc7-bd18-b34a34ee5a85" />

That said, I still think Codex should provide a proper Delete Project option for Codex Cloud.

_Until then, I'd recommend being extra careful not to accidentally include secrets, API keys, credentials, or other sensitive information in a project. Without a delete option, users have limited control over removing cloud project history, which is an important capability for privacy and security._

Suggestion - Don't buy and use codex

Adolanium · 1 month ago

Any update on this? This should be addressed ASAP.

Citizen3rased · 1 month ago

Is this being worked on? This is a serious issue.

ehzawad · 1 month ago

Implement this ASAP tibo

LoganPendragonmulti · 1 month ago

I agree. This has been a really annoying issue for a long time.

greydenwp · 1 month ago

Working self-service deletion via the Codex web API

Archived Codex Cloud tasks can be permanently removed from your own account. The backend supports it; the UI just never got a button. Verified on a legacy task that had been stuck and undeletable for a long time.

Log in at https://chatgpt.com, open the task so its URL shows the task id (https://chatgpt.com/codex/cloud/tasks/task_e_...), then in the browser DevTools console:

const { accessToken } = await (await fetch('/api/auth/session')).json();
const TASK_ID = 'task_e_xxxxxxxxxxxx'; // from the task URL
const r = await fetch(`/backend-api/wham/tasks/${TASK_ID}`, {
  method: 'DELETE',
  headers: { Authorization: `Bearer ${accessToken}` }
});
console.log(r.status); // 200 = deleted

After a 200, GET /backend-api/wham/tasks/{id} returns 404 {"detail":"Invalid task ID"}, the task disappears from the Archive tab, and the task list returns 0.

Warning for anyone investigating this: an unauthenticated OPTIONS on that path returns 405 Allow: GET, and /tasks/{id}/delete returns 404. Both make deletion look impossible. I concluded exactly that before testing the real request. The authenticated DELETE works regardless. Don't trust the Allow header here; it appears to be answered from a pre-auth route table.

This only touches your own account's own tasks, using the same endpoint the site itself uses.

Worth noting: the task I deleted carried conversation_id: "None", meaning it was not backed by a ChatGPT conversation at all. I also confirmed it never appeared in Settings > Data controls > Archived chats. That very likely explains why the "archive it, then delete from Archived chats" advice in these threads works for some people and does nothing for others.

Other issues on this topic: #24417 and #23991 (open), #13018, #23837 and #24343 (closed). I searched all of them (69 comments), plus discussions #1272 and #1442 and the OpenAI community forum, and found no mention of backend-api, wham, DevTools, or a DELETE request. As far as I can find this hasn't been posted publicly, though the endpoint is undocumented and OpenAI could change or remove it at any time.

Since the endpoint already exists and works, this looks like a missing UI affordance rather than a missing capability.

Adolanium · 1 month ago

@greydenwp You are a lifesaver. Good job on finding this.

Aesthermortis · 1 month ago
Working self-service deletion via the Codex web API Archived Codex Cloud tasks _can_ be permanently removed from your own account. The backend supports it; the UI just never got a button. Verified on a legacy task that had been stuck and undeletable for a long time. Log in at https://chatgpt.com, open the task so its URL shows the task id (https://chatgpt.com/codex/cloud/tasks/task_e_...), then in the browser DevTools console: const { accessToken } = await (await fetch('/api/auth/session')).json(); const TASK_ID = 'task_e_xxxxxxxxxxxx'; // from the task URL const r = await fetch(/backend-api/wham/tasks/${TASK_ID}, { method: 'DELETE', headers: { Authorization: Bearer ${accessToken} } }); console.log(r.status); // 200 = deleted After a 200, GET /backend-api/wham/tasks/{id} returns 404 {"detail":"Invalid task ID"}, the task disappears from the Archive tab, and the task list returns 0. Warning for anyone investigating this: an unauthenticated OPTIONS on that path returns 405 Allow: GET, and /tasks/{id}/delete returns 404. Both make deletion look impossible. I concluded exactly that before testing the real request. The authenticated DELETE works regardless. Don't trust the Allow header here; it appears to be answered from a pre-auth route table. This only touches your own account's own tasks, using the same endpoint the site itself uses. Worth noting: the task I deleted carried conversation_id: "None", meaning it was not backed by a ChatGPT conversation at all. I also confirmed it never appeared in Settings > Data controls > Archived chats. That very likely explains why the "archive it, then delete from Archived chats" advice in these threads works for some people and does nothing for others. Other issues on this topic: #24417 and #23991 (open), #13018, #23837 and #24343 (closed). I searched all of them (69 comments), plus discussions #1272 and #1442 and the OpenAI community forum, and found no mention of backend-api, wham, DevTools, or a DELETE request. As far as I can find this hasn't been posted publicly, though the endpoint is undocumented and OpenAI could change or remove it at any time. Since the endpoint already exists and works, this looks like a missing UI affordance rather than a missing capability.

Thank you so much, I created one recursively because it's too much of a hassle to do them one by one. The cloud mode really does seem more usable now instead of having a huge list of tasks from months ago sitting there.

(async () => {
  const CONFIG = {
    dryRun: false,          // Cambia a false después de revisar los IDs
    concurrency: 3,       // Eliminaciones simultáneas
    maxDiscoveryRounds: 30,
    stableRoundsToStop: 3,
    discoveryDelayMs: 1000,
    maxDeleteRetries: 4,
  };

  const TASK_ID_PATTERN = /\btask_e_[a-f0-9]{32}\b/gi;
  const discoveredTaskIds = new Set();

  const sleep = (ms) =>
    new Promise((resolve) => setTimeout(resolve, ms));

  function collectVisibleTaskIds() {
    const sources = [
      document.body?.innerText ?? "",
      location.href,
      ...Array.from(document.querySelectorAll("a[href]"), (link) => link.href),
    ];

    const matches = sources
      .join("\n")
      .match(TASK_ID_PATTERN);

    for (const taskId of matches ?? []) {
      discoveredTaskIds.add(taskId);
    }
  }

  function scrollTaskContainers() {
    window.scrollTo({
      top: document.documentElement.scrollHeight,
      behavior: "instant",
    });

    const scrollableElements = Array.from(
      document.querySelectorAll("*"),
    ).filter((element) => {
      const style = getComputedStyle(element);

      return (
        element.scrollHeight > element.clientHeight + 100 &&
        ["auto", "scroll"].includes(style.overflowY)
      );
    });

    for (const element of scrollableElements) {
      element.scrollTop = element.scrollHeight;
    }
  }

  async function discoverAllTaskIds() {
    let previousCount = -1;
    let stableRounds = 0;

    for (
      let round = 1;
      round <= CONFIG.maxDiscoveryRounds;
      round++
    ) {
      collectVisibleTaskIds();
      scrollTaskContainers();
      await sleep(CONFIG.discoveryDelayMs);
      collectVisibleTaskIds();

      const currentCount = discoveredTaskIds.size;

      console.log(
        `[discovery ${round}] ${currentCount} tareas encontradas`,
      );

      if (currentCount === previousCount) {
        stableRounds++;
      } else {
        stableRounds = 0;
      }

      if (stableRounds >= CONFIG.stableRoundsToStop) {
        break;
      }

      previousCount = currentCount;
    }

    return [...discoveredTaskIds];
  }

  async function getAccessToken() {
    const response = await fetch("/api/auth/session");

    if (!response.ok) {
      throw new Error(
        `No se pudo obtener la sesión: HTTP ${response.status}`,
      );
    }

    const session = await response.json();

    if (!session.accessToken) {
      throw new Error(
        "La sesión no contiene accessToken. Recarga la página e inténtalo de nuevo.",
      );
    }

    return session.accessToken;
  }

  async function deleteTask(taskId, accessToken) {
    for (
      let attempt = 0;
      attempt <= CONFIG.maxDeleteRetries;
      attempt++
    ) {
      const response = await fetch(
        `/backend-api/wham/tasks/${encodeURIComponent(taskId)}`,
        {
          method: "DELETE",
          headers: {
            Authorization: `Bearer ${accessToken}`,
          },
        },
      );

      if (response.ok || response.status === 404) {
        return {
          taskId,
          status: response.status,
          deleted: response.ok,
          alreadyMissing: response.status === 404,
        };
      }

      const retryable =
        response.status === 429 ||
        response.status >= 500;

      if (!retryable || attempt === CONFIG.maxDeleteRetries) {
        return {
          taskId,
          status: response.status,
          deleted: false,
          error: await response.text().catch(() => ""),
        };
      }

      const retryAfter = Number(
        response.headers.get("retry-after"),
      );

      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1000
        : 1000 * 2 ** attempt;

      console.warn(
        `${taskId}: HTTP ${response.status}; reintento en ${delayMs} ms`,
      );

      await sleep(delayMs);
    }
  }

  async function deleteWithConcurrency(taskIds, accessToken) {
    const results = [];
    let cursor = 0;

    async function worker() {
      while (true) {
        const index = cursor++;

        if (index >= taskIds.length) {
          return;
        }

        const taskId = taskIds[index];
        const result = await deleteTask(taskId, accessToken);

        results.push(result);

        console.log(
          `[${index + 1}/${taskIds.length}]`,
          taskId,
          result.status,
        );
      }
    }

    const workerCount = Math.min(
      CONFIG.concurrency,
      taskIds.length,
    );

    await Promise.all(
      Array.from({ length: workerCount }, () => worker()),
    );

    return results;
  }

  console.log("Buscando tareas…");

  const taskIds = await discoverAllTaskIds();

  if (taskIds.length === 0) {
    console.warn(
      "No se encontraron IDs. Ejecuta el script desde la lista de tareas y asegúrate de que esté visible.",
    );
    return;
  }

  console.table(
    taskIds.map((taskId) => ({ taskId })),
  );

  if (CONFIG.dryRun) {
    console.warn(
      `DRY RUN: se encontraron ${taskIds.length} tareas. ` +
      "Cambia CONFIG.dryRun a false para eliminarlas.",
    );
    return;
  }

  const confirmed = confirm(
    `Se eliminarán ${taskIds.length} tareas de Codex.\n\n` +
    "Esta operación puede ser irreversible.\n\n" +
    "¿Continuar?",
  );

  if (!confirmed) {
    console.log("Operación cancelada.");
    return;
  }

  const accessToken = await getAccessToken();
  const results = await deleteWithConcurrency(
    taskIds,
    accessToken,
  );

  const deleted = results.filter(
    (result) => result.deleted,
  );

  const alreadyMissing = results.filter(
    (result) => result.alreadyMissing,
  );

  const failed = results.filter(
    (result) =>
      !result.deleted && !result.alreadyMissing,
  );

  console.table(results);

  console.log({
    discovered: taskIds.length,
    deleted: deleted.length,
    alreadyMissing: alreadyMissing.length,
    failed: failed.length,
  });

  if (failed.length === 0) {
    console.log(
      "Todas las tareas fueron procesadas. Recarga la página.",
    );
  } else {
    console.warn(
      "Algunas tareas no pudieron eliminarse:",
      failed,
    );
  }
})();
Citizen3rased · 1 month ago
> Working self-service deletion via the Codex web API > Archived Codex Cloud tasks _can_ be permanently removed from your own account. The backend supports it; the UI just never got a button. Verified on a legacy task that had been stuck and undeletable for a long time. > Log in at https://chatgpt.com, open the task so its URL shows the task id (https://chatgpt.com/codex/cloud/tasks/task_e_...), then in the browser DevTools console: > const { accessToken } = await (await fetch('/api/auth/session')).json(); > const TASK_ID = 'task_e_xxxxxxxxxxxx'; // from the task URL > const r = await fetch(/backend-api/wham/tasks/${TASK_ID}, { > method: 'DELETE', > headers: { Authorization: Bearer ${accessToken} } > }); > console.log(r.status); // 200 = deleted > After a 200, GET /backend-api/wham/tasks/{id} returns 404 {"detail":"Invalid task ID"}, the task disappears from the Archive tab, and the task list returns 0. > Warning for anyone investigating this: an unauthenticated OPTIONS on that path returns 405 Allow: GET, and /tasks/{id}/delete returns 404. Both make deletion look impossible. I concluded exactly that before testing the real request. The authenticated DELETE works regardless. Don't trust the Allow header here; it appears to be answered from a pre-auth route table. > This only touches your own account's own tasks, using the same endpoint the site itself uses. > Worth noting: the task I deleted carried conversation_id: "None", meaning it was not backed by a ChatGPT conversation at all. I also confirmed it never appeared in Settings > Data controls > Archived chats. That very likely explains why the "archive it, then delete from Archived chats" advice in these threads works for some people and does nothing for others. > Other issues on this topic: #24417 and #23991 (open), #13018, #23837 and #24343 (closed). I searched all of them (69 comments), plus discussions #1272 and #1442 and the OpenAI community forum, and found no mention of backend-api, wham, DevTools, or a DELETE request. As far as I can find this hasn't been posted publicly, though the endpoint is undocumented and OpenAI could change or remove it at any time. > Since the endpoint already exists and works, this looks like a missing UI affordance rather than a missing capability. Thank you so much, I created one recursively because it's too much of a hassle to do them one by one. The cloud mode really does seem more usable now instead of having a huge list of tasks from months ago sitting there. `` (async () => { const CONFIG = { dryRun: false, // Cambia a false después de revisar los IDs concurrency: 3, // Eliminaciones simultáneas maxDiscoveryRounds: 30, stableRoundsToStop: 3, discoveryDelayMs: 1000, maxDeleteRetries: 4, }; const TASK_ID_PATTERN = /\btask_e_[a-f0-9]{32}\b/gi; const discoveredTaskIds = new Set(); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function collectVisibleTaskIds() { const sources = [ document.body?.innerText ?? "", location.href, ...Array.from(document.querySelectorAll("a[href]"), (link) => link.href), ]; const matches = sources .join("\n") .match(TASK_ID_PATTERN); for (const taskId of matches ?? []) { discoveredTaskIds.add(taskId); } } function scrollTaskContainers() { window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant", }); const scrollableElements = Array.from( document.querySelectorAll("*"), ).filter((element) => { const style = getComputedStyle(element); return ( element.scrollHeight > element.clientHeight + 100 && ["auto", "scroll"].includes(style.overflowY) ); }); for (const element of scrollableElements) { element.scrollTop = element.scrollHeight; } } async function discoverAllTaskIds() { let previousCount = -1; let stableRounds = 0; for ( let round = 1; round <= CONFIG.maxDiscoveryRounds; round++ ) { collectVisibleTaskIds(); scrollTaskContainers(); await sleep(CONFIG.discoveryDelayMs); collectVisibleTaskIds(); const currentCount = discoveredTaskIds.size; console.log( [discovery ${round}] ${currentCount} tareas encontradas, ); if (currentCount === previousCount) { stableRounds++; } else { stableRounds = 0; } if (stableRounds >= CONFIG.stableRoundsToStop) { break; } previousCount = currentCount; } return [...discoveredTaskIds]; } async function getAccessToken() { const response = await fetch("/api/auth/session"); if (!response.ok) { throw new Error( No se pudo obtener la sesión: HTTP ${response.status}, ); } const session = await response.json(); if (!session.accessToken) { throw new Error( "La sesión no contiene accessToken. Recarga la página e inténtalo de nuevo.", ); } return session.accessToken; } async function deleteTask(taskId, accessToken) { for ( let attempt = 0; attempt <= CONFIG.maxDeleteRetries; attempt++ ) { const response = await fetch( /backend-api/wham/tasks/${encodeURIComponent(taskId)}, { method: "DELETE", headers: { Authorization: Bearer ${accessToken}, }, }, ); if (response.ok || response.status === 404) { return { taskId, status: response.status, deleted: response.ok, alreadyMissing: response.status === 404, }; } const retryable = response.status === 429 || response.status >= 500; if (!retryable || attempt === CONFIG.maxDeleteRetries) { return { taskId, status: response.status, deleted: false, error: await response.text().catch(() => ""), }; } const retryAfter = Number( response.headers.get("retry-after"), ); const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 1000 * 2 ** attempt; console.warn( ${taskId}: HTTP ${response.status}; reintento en ${delayMs} ms, ); await sleep(delayMs); } } async function deleteWithConcurrency(taskIds, accessToken) { const results = []; let cursor = 0; async function worker() { while (true) { const index = cursor++; if (index >= taskIds.length) { return; } const taskId = taskIds[index]; const result = await deleteTask(taskId, accessToken); results.push(result); console.log( [${index + 1}/${taskIds.length}], taskId, result.status, ); } } const workerCount = Math.min( CONFIG.concurrency, taskIds.length, ); await Promise.all( Array.from({ length: workerCount }, () => worker()), ); return results; } console.log("Buscando tareas…"); const taskIds = await discoverAllTaskIds(); if (taskIds.length === 0) { console.warn( "No se encontraron IDs. Ejecuta el script desde la lista de tareas y asegúrate de que esté visible.", ); return; } console.table( taskIds.map((taskId) => ({ taskId })), ); if (CONFIG.dryRun) { console.warn( DRY RUN: se encontraron ${taskIds.length} tareas. + "Cambia CONFIG.dryRun a false para eliminarlas.", ); return; } const confirmed = confirm( Se eliminarán ${taskIds.length} tareas de Codex.\n\n + "Esta operación puede ser irreversible.\n\n" + "¿Continuar?", ); if (!confirmed) { console.log("Operación cancelada."); return; } const accessToken = await getAccessToken(); const results = await deleteWithConcurrency( taskIds, accessToken, ); const deleted = results.filter( (result) => result.deleted, ); const alreadyMissing = results.filter( (result) => result.alreadyMissing, ); const failed = results.filter( (result) => !result.deleted && !result.alreadyMissing, ); console.table(results); console.log({ discovered: taskIds.length, deleted: deleted.length, alreadyMissing: alreadyMissing.length, failed: failed.length, }); if (failed.length === 0) { console.log( "Todas las tareas fueron procesadas. Recarga la página.", ); } else { console.warn( "Algunas tareas no pudieron eliminarse:", failed, ); } })(); ``

Tested this and it worked to clear out my old archived tasks in the cloud. Thank you.

jyxjjj · 1 month ago

Thank you for the code. The world is finally quiet again.