@viraatdas/rudder 2.11.3 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cloud.js CHANGED
@@ -99,6 +99,13 @@ const BULKY_HOME_BASENAME_PATTERNS = [
99
99
  /\.jsonl$/,
100
100
  ];
101
101
  export async function runCloudCommand(command, args, options = {}) {
102
+ // Hidden diagnostic flag: measure keystroke round-trip latency instead of
103
+ // starting an interactive attach. Parsed here because main.ts forwards
104
+ // unknown flags through as positional args.
105
+ if (args.includes("--latency-probe")) {
106
+ args = args.filter((arg) => arg !== "--latency-probe");
107
+ options = { ...options, latencyProbe: true };
108
+ }
102
109
  const subcommand = args[0] ?? "";
103
110
  const rest = args.slice(1);
104
111
  if (command === "cloud" && subcommand === "help") {
@@ -203,6 +210,12 @@ export async function runCloudCommand(command, args, options = {}) {
203
210
  case "runtime":
204
211
  await runtime(rest, options);
205
212
  return;
213
+ case "region":
214
+ await configureRegion(rest, options);
215
+ return;
216
+ case "secrets":
217
+ await secretsCommand(rest, options);
218
+ return;
206
219
  default:
207
220
  // A bare `rudder cloud "<text>"` / `rudder sail "<text>"` is the documented
208
221
  // way to launch a worker ON that task (the instance name is derived from it).
@@ -1113,27 +1126,30 @@ async function createSnapshot(repoRoot, requestedHomePaths, options = {}) {
1113
1126
  ? await copyProjectEnvFiles(repoRoot, repoStage)
1114
1127
  : 0;
1115
1128
  const rudderState = options.includeRudderState ? await copyRudderState(repoRoot, repoStage) : undefined;
1116
- const homePaths = normalizeHomePaths(requestedHomePaths);
1129
+ const includeCredentials = options.includeCredentials !== false;
1117
1130
  const includedHomePaths = [];
1118
- for (const homePath of homePaths) {
1119
- const copied = await copyHomePath(homePath, homeStage);
1120
- if (copied) {
1121
- includedHomePaths.push(shortenHome(homePath));
1122
- }
1123
- }
1124
- // On macOS, Claude Code stores its OAuth token in the Keychain rather than
1125
- // ~/.claude/.credentials.json, so the home-paths copy above doesn't pick it
1126
- // up. Extract it from the Keychain and stage it as a credentials file so
1127
- // the cloud worker boots already logged in.
1128
- if (await stageClaudeKeychainCredentials(homeStage)) {
1129
- includedHomePaths.push("~/.claude/.credentials.json (keychain)");
1130
- }
1131
- const capturedEnv = captureCloudEnv(Boolean(options.migration));
1132
1131
  let capturedEnvCount = 0;
1133
- if (Object.keys(capturedEnv).length > 0) {
1134
- await ensureDir(path.join(stageDir, "env"));
1135
- await writeJson(path.join(stageDir, "env", "cloud-env.json"), capturedEnv);
1136
- capturedEnvCount = Object.keys(capturedEnv).length;
1132
+ if (includeCredentials) {
1133
+ const homePaths = normalizeHomePaths(requestedHomePaths);
1134
+ for (const homePath of homePaths) {
1135
+ const copied = await copyHomePath(homePath, homeStage);
1136
+ if (copied) {
1137
+ includedHomePaths.push(shortenHome(homePath));
1138
+ }
1139
+ }
1140
+ // On macOS, Claude Code stores its OAuth token in the Keychain rather than
1141
+ // ~/.claude/.credentials.json, so the home-paths copy above doesn't pick it
1142
+ // up. Extract it from the Keychain and stage it as a credentials file so
1143
+ // the cloud worker boots already logged in.
1144
+ if (await stageClaudeKeychainCredentials(homeStage)) {
1145
+ includedHomePaths.push("~/.claude/.credentials.json (keychain)");
1146
+ }
1147
+ const capturedEnv = captureCloudEnv(Boolean(options.migration));
1148
+ if (Object.keys(capturedEnv).length > 0) {
1149
+ await ensureDir(path.join(stageDir, "env"));
1150
+ await writeJson(path.join(stageDir, "env", "cloud-env.json"), capturedEnv);
1151
+ capturedEnvCount = Object.keys(capturedEnv).length;
1152
+ }
1137
1153
  }
1138
1154
  let migratedAgentsCount = 0;
1139
1155
  if (options.migration && options.migration.plan.migrated.length > 0) {
@@ -1466,25 +1482,34 @@ function normalizeHomePaths(requested) {
1466
1482
  }
1467
1483
  return paths;
1468
1484
  }
1469
- async function stageClaudeKeychainCredentials(homeStage) {
1485
+ // On macOS, Claude Code keeps its OAuth token in the Keychain instead of
1486
+ // ~/.claude/.credentials.json. Read it so cloud workspaces boot logged in.
1487
+ async function readClaudeKeychainCredentials() {
1470
1488
  if (process.platform !== "darwin") {
1471
- return false;
1489
+ return null;
1472
1490
  }
1473
1491
  if (!commandExists("security")) {
1474
- return false;
1492
+ return null;
1475
1493
  }
1476
1494
  const result = await runCommand("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { allowFailure: true });
1477
1495
  if (result.code !== 0) {
1478
- return false;
1496
+ return null;
1479
1497
  }
1480
1498
  const payload = result.stdout.trim();
1481
1499
  if (!payload || !payload.startsWith("{")) {
1482
- return false;
1500
+ return null;
1483
1501
  }
1484
1502
  try {
1485
1503
  JSON.parse(payload);
1486
1504
  }
1487
1505
  catch {
1506
+ return null;
1507
+ }
1508
+ return payload;
1509
+ }
1510
+ async function stageClaudeKeychainCredentials(homeStage) {
1511
+ const payload = await readClaudeKeychainCredentials();
1512
+ if (!payload) {
1488
1513
  return false;
1489
1514
  }
1490
1515
  const targetDir = path.join(homeStage, ".claude");
@@ -1688,6 +1713,10 @@ async function workspaceCommand(args, options) {
1688
1713
  await workspaceAttach(rest, options);
1689
1714
  return;
1690
1715
  }
1716
+ if (sub === "create") {
1717
+ await workspaceCreate(rest, options);
1718
+ return;
1719
+ }
1691
1720
  if (sub === "share") {
1692
1721
  await workspaceShare(options);
1693
1722
  return;
@@ -1704,39 +1733,394 @@ async function workspaceCommand(args, options) {
1704
1733
  await workspaceList(options);
1705
1734
  return;
1706
1735
  }
1707
- throw new Error("Usage: rudder cloud workspace [attach [id]|share|status|pause|resume|stop|list]");
1736
+ throw new Error("Usage: rudder cloud workspace [attach [id|owner/repo]|create <owner/repo>|share|status|pause|resume|stop|list]");
1737
+ }
1738
+ const GITHUB_SLUG_RE = /^[\w.-]+\/[\w.-]+$/;
1739
+ async function githubSlugFromOrigin(repoRoot) {
1740
+ const result = await runCommand("git", ["remote", "get-url", "origin"], {
1741
+ cwd: repoRoot,
1742
+ allowFailure: true,
1743
+ });
1744
+ if (result.code !== 0) {
1745
+ return null;
1746
+ }
1747
+ const match = result.stdout.trim().match(/github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?$/);
1748
+ return match ? match[1] : null;
1749
+ }
1750
+ // Cloud-native workspace: the worker clones the repo from GitHub directly —
1751
+ // full history, real origin remote, no local-directory upload.
1752
+ async function workspaceCreate(args, options) {
1753
+ const slug = (args[0] ?? "").trim().replace(/\.git$/, "");
1754
+ if (!GITHUB_SLUG_RE.test(slug)) {
1755
+ throw new Error("Usage: rudder cloud workspace create <owner/repo> [--branch <name>] [--region <code>]");
1756
+ }
1757
+ let branch;
1758
+ let region;
1759
+ for (let i = 1; i < args.length; i += 1) {
1760
+ const arg = args[i] ?? "";
1761
+ if (arg === "--branch")
1762
+ branch = args[++i];
1763
+ else if (arg.startsWith("--branch="))
1764
+ branch = arg.slice("--branch=".length);
1765
+ else if (arg === "--region")
1766
+ region = args[++i];
1767
+ else if (arg.startsWith("--region="))
1768
+ region = arg.slice("--region=".length);
1769
+ else
1770
+ throw new Error(`Unknown option: ${arg}`);
1771
+ }
1772
+ const client = await cloudClient({ requireToken: true });
1773
+ // Preflight: private clones and pushes need GitHub credentials from the vault.
1774
+ try {
1775
+ const secretsResult = await client.request("/api/rudder/secrets", { method: "GET" });
1776
+ const secrets = secretsResult?.secrets ?? [];
1777
+ const hasGitCreds = secrets.some((secret) => (secret.kind === "file" && secret.name.startsWith("~/.config/gh/"))
1778
+ || (secret.kind === "env" && (secret.name === "GITHUB_TOKEN" || secret.name === "GH_TOKEN")));
1779
+ if (!hasGitCreds && !options.json) {
1780
+ process.stderr.write("Warning: no GitHub credentials in the cloud vault; private repos will fail to clone. Run `rudder cloud secrets sync` first.\n");
1781
+ }
1782
+ }
1783
+ catch {
1784
+ // Old server or unconfigured vault; the worker will report clone failures.
1785
+ }
1786
+ if (!options.json) {
1787
+ process.stderr.write(`Creating cloud workspace for ${slug}...\n`);
1788
+ }
1789
+ const effectiveRegion = region ?? await explicitCloudRegion();
1790
+ const result = await client.request("/api/rudder/workspace/create", {
1791
+ method: "POST",
1792
+ body: {
1793
+ repo: slug,
1794
+ ...(branch ? { branch } : {}),
1795
+ ...(effectiveRegion ? { region: effectiveRegion } : {}),
1796
+ },
1797
+ });
1798
+ await attachToWorkspaceResult(result, options);
1799
+ }
1800
+ async function workspaceAttachByRepo(slug, options) {
1801
+ const normalized = slug.trim().replace(/\.git$/, "");
1802
+ const client = await cloudClient({ requireToken: true });
1803
+ try {
1804
+ await client.request(`/api/rudder/workspace/lookup?repo=${encodeURIComponent(normalized)}`, { method: "GET" });
1805
+ }
1806
+ catch {
1807
+ throw new Error(`No cloud workspace for ${normalized}. Create one with \`rudder cloud workspace create ${normalized}\`.`);
1808
+ }
1809
+ // The create endpoint reuses/warm-restarts an existing clone workspace.
1810
+ const result = await client.request("/api/rudder/workspace/create", {
1811
+ method: "POST",
1812
+ body: { repo: normalized },
1813
+ });
1814
+ await attachToWorkspaceResult(result, options);
1815
+ }
1816
+ const MAX_SECRET_VALUE_BYTES = 1024 * 1024;
1817
+ async function secretsCommand(args, options) {
1818
+ const sub = args[0] ?? "";
1819
+ const rest = args.slice(1);
1820
+ switch (sub) {
1821
+ case "set":
1822
+ await secretsSet(rest, options);
1823
+ return;
1824
+ case "list":
1825
+ case "ls":
1826
+ await secretsList(options);
1827
+ return;
1828
+ case "rm":
1829
+ case "remove":
1830
+ case "delete":
1831
+ await secretsRm(rest, options);
1832
+ return;
1833
+ case "sync":
1834
+ await secretsSync(options);
1835
+ return;
1836
+ default:
1837
+ throw new Error("Usage: rudder cloud secrets [set <NAME> [value] | set --file <~/path> [source] | list | rm <NAME> | sync]");
1838
+ }
1839
+ }
1840
+ // Convert an absolute or ~-prefixed path into the canonical tilde form the
1841
+ // vault stores file secrets under. Only paths inside $HOME are allowed.
1842
+ function toTildePath(input) {
1843
+ const trimmed = input.trim();
1844
+ const resolved = path.resolve(expandHome(trimmed));
1845
+ const home = os.homedir();
1846
+ if (!isInside(home, resolved) || resolved === home) {
1847
+ throw new Error(`File secrets must live inside your home directory: ${input}`);
1848
+ }
1849
+ return `~/${path.relative(home, resolved).split(path.sep).join("/")}`;
1850
+ }
1851
+ async function readStdinAll() {
1852
+ const chunks = [];
1853
+ for await (const chunk of process.stdin) {
1854
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1855
+ }
1856
+ return Buffer.concat(chunks).toString("utf8");
1857
+ }
1858
+ async function secretsSet(args, options) {
1859
+ const client = await cloudClient({ requireToken: true });
1860
+ if (args[0] === "--file") {
1861
+ const target = args[1];
1862
+ if (!target) {
1863
+ throw new Error("Usage: rudder cloud secrets set --file <~/path> [localSourcePath]");
1864
+ }
1865
+ const tildePath = toTildePath(target);
1866
+ const sourcePath = path.resolve(expandHome(args[2] ?? target));
1867
+ const content = await fsp.readFile(sourcePath);
1868
+ if (content.length > MAX_SECRET_VALUE_BYTES) {
1869
+ throw new Error(`${sourcePath} is ${content.length} bytes; file secrets are capped at ${MAX_SECRET_VALUE_BYTES}`);
1870
+ }
1871
+ await client.request("/api/rudder/secrets/item", {
1872
+ method: "PUT",
1873
+ body: {
1874
+ name: tildePath,
1875
+ kind: "file",
1876
+ filePath: tildePath,
1877
+ valueBase64: content.toString("base64"),
1878
+ source: "manual",
1879
+ },
1880
+ });
1881
+ if (options.json) {
1882
+ printJson({ ok: true, name: tildePath, kind: "file" });
1883
+ }
1884
+ else {
1885
+ console.log(`Stored file secret ${tildePath} (${content.length} bytes). Takes effect on next workspace boot.`);
1886
+ }
1887
+ return;
1888
+ }
1889
+ const name = args[0];
1890
+ if (!name) {
1891
+ throw new Error("Usage: rudder cloud secrets set <NAME> [value] (or pipe the value on stdin)");
1892
+ }
1893
+ let value = args[1];
1894
+ if (value === undefined) {
1895
+ value = process.stdin.isTTY
1896
+ ? await promptSecret(`Value for ${name}`)
1897
+ : (await readStdinAll()).replace(/\r?\n$/, "");
1898
+ }
1899
+ if (!value) {
1900
+ throw new Error(`No value provided for ${name}.`);
1901
+ }
1902
+ if (Buffer.byteLength(value, "utf8") > MAX_SECRET_VALUE_BYTES) {
1903
+ throw new Error(`Value for ${name} exceeds the ${MAX_SECRET_VALUE_BYTES}-byte cap`);
1904
+ }
1905
+ await client.request("/api/rudder/secrets/item", {
1906
+ method: "PUT",
1907
+ body: {
1908
+ name,
1909
+ kind: "env",
1910
+ valueBase64: Buffer.from(value, "utf8").toString("base64"),
1911
+ source: "manual",
1912
+ },
1913
+ });
1914
+ if (options.json) {
1915
+ printJson({ ok: true, name, kind: "env" });
1916
+ }
1917
+ else {
1918
+ console.log(`Stored env secret ${name}. Takes effect on next workspace boot.`);
1919
+ }
1920
+ }
1921
+ async function secretsList(options) {
1922
+ const client = await cloudClient({ requireToken: true });
1923
+ const result = await client.request("/api/rudder/secrets", { method: "GET" });
1924
+ const secrets = result?.secrets ?? [];
1925
+ if (options.json) {
1926
+ printJson(result);
1927
+ return;
1928
+ }
1929
+ if (secrets.length === 0) {
1930
+ console.log("No cloud secrets stored. Run `rudder cloud secrets sync` to import your local credentials.");
1931
+ return;
1932
+ }
1933
+ const nameWidth = Math.max(4, ...secrets.map((secret) => secret.name.length));
1934
+ console.log(`${"NAME".padEnd(nameWidth)} KIND SIZE UPDATED`);
1935
+ for (const secret of secrets) {
1936
+ const size = `${secret.sizeBytes}B`.padEnd(8);
1937
+ console.log(`${secret.name.padEnd(nameWidth)} ${secret.kind.padEnd(4)} ${size} ${secret.updatedAt}`);
1938
+ }
1939
+ console.log(`\n${secrets.length} secret(s). Values are never shown; rotate with \`rudder cloud secrets set\`.`);
1940
+ }
1941
+ async function secretsRm(args, options) {
1942
+ const name = args[0];
1943
+ if (!name) {
1944
+ throw new Error("Usage: rudder cloud secrets rm <NAME|~/path>");
1945
+ }
1946
+ const client = await cloudClient({ requireToken: true });
1947
+ const normalized = name.startsWith("~") || name.startsWith("/") ? toTildePath(name) : name;
1948
+ await client.request(`/api/rudder/secrets/item?name=${encodeURIComponent(normalized)}`, {
1949
+ method: "DELETE",
1950
+ });
1951
+ if (options.json) {
1952
+ printJson({ ok: true, name: normalized });
1953
+ }
1954
+ else {
1955
+ console.log(`Removed cloud secret ${normalized}.`);
1956
+ }
1957
+ }
1958
+ async function collectHomeSecretFiles() {
1959
+ const home = os.homedir();
1960
+ const out = [];
1961
+ const walk = async (target) => {
1962
+ const stat = await fsp.lstat(target).catch(() => null);
1963
+ if (!stat) {
1964
+ return;
1965
+ }
1966
+ if (stat.isSymbolicLink()) {
1967
+ return;
1968
+ }
1969
+ if (stat.isDirectory()) {
1970
+ if (!(await shouldIncludeSnapshotPath(target))) {
1971
+ return;
1972
+ }
1973
+ const entries = await fsp.readdir(target).catch(() => []);
1974
+ for (const entry of entries) {
1975
+ await walk(path.join(target, entry));
1976
+ }
1977
+ return;
1978
+ }
1979
+ if (!stat.isFile() || !(await shouldIncludeSnapshotPath(target))) {
1980
+ return;
1981
+ }
1982
+ out.push({
1983
+ tildePath: `~/${path.relative(home, target).split(path.sep).join("/")}`,
1984
+ absolute: target,
1985
+ size: stat.size,
1986
+ });
1987
+ };
1988
+ for (const root of normalizeHomePaths([])) {
1989
+ await walk(root);
1990
+ }
1991
+ return out;
1992
+ }
1993
+ // One-time (re-runnable) import of the credentials that used to ride inside
1994
+ // every workspace snapshot: the DEFAULT_HOME_PATHS allowlist, the macOS
1995
+ // Keychain Claude token, and the captured env vars.
1996
+ async function secretsSync(options) {
1997
+ const client = await cloudClient({ requireToken: true });
1998
+ const items = [];
1999
+ const skipped = [];
2000
+ for (const file of await collectHomeSecretFiles()) {
2001
+ if (file.size === 0) {
2002
+ continue;
2003
+ }
2004
+ if (file.size > MAX_SECRET_VALUE_BYTES) {
2005
+ skipped.push({ name: file.tildePath, reason: `${file.size} bytes exceeds the per-secret cap` });
2006
+ continue;
2007
+ }
2008
+ const content = await fsp.readFile(file.absolute).catch(() => null);
2009
+ if (!content) {
2010
+ skipped.push({ name: file.tildePath, reason: "unreadable" });
2011
+ continue;
2012
+ }
2013
+ items.push({
2014
+ name: file.tildePath,
2015
+ kind: "file",
2016
+ filePath: file.tildePath,
2017
+ valueBase64: content.toString("base64"),
2018
+ source: "sync",
2019
+ });
2020
+ }
2021
+ // Keychain read can pop a macOS auth dialog, so only attempt it when a
2022
+ // human is at the terminal to answer it.
2023
+ if (isTty()) {
2024
+ const keychainPayload = await readClaudeKeychainCredentials();
2025
+ if (keychainPayload) {
2026
+ items.push({
2027
+ name: "~/.claude/.credentials.json",
2028
+ kind: "file",
2029
+ filePath: "~/.claude/.credentials.json",
2030
+ valueBase64: Buffer.from(keychainPayload + "\n", "utf8").toString("base64"),
2031
+ source: "sync",
2032
+ });
2033
+ }
2034
+ }
2035
+ for (const [name, value] of Object.entries(captureCloudEnv())) {
2036
+ items.push({
2037
+ name,
2038
+ kind: "env",
2039
+ valueBase64: Buffer.from(value, "utf8").toString("base64"),
2040
+ source: "sync",
2041
+ });
2042
+ }
2043
+ if (items.length === 0) {
2044
+ throw new Error("Found nothing to sync: no allowlisted credential files or matching env vars.");
2045
+ }
2046
+ const response = await client.request("/api/rudder/secrets/bulk", { method: "POST", body: { items } });
2047
+ const results = response?.results ?? [];
2048
+ const stored = results.filter((entry) => entry.ok);
2049
+ const failed = results.filter((entry) => !entry.ok);
2050
+ if (options.json) {
2051
+ printJson({ stored: stored.length, failed, skipped });
2052
+ return;
2053
+ }
2054
+ console.log(`Synced ${stored.length} secret(s) to the cloud vault:`);
2055
+ for (const entry of stored) {
2056
+ console.log(` ${entry.name}`);
2057
+ }
2058
+ for (const entry of failed) {
2059
+ console.log(` FAILED ${entry.name}: ${entry.error ?? "unknown error"}`);
2060
+ }
2061
+ for (const entry of skipped) {
2062
+ console.log(` SKIPPED ${entry.name}: ${entry.reason}`);
2063
+ }
2064
+ console.log("\nNew cloud workspaces will now boot with these secrets; snapshots stop carrying local credentials.");
1708
2065
  }
1709
2066
  function computeWorkspaceKey(repoRoot) {
1710
2067
  const normalized = path.resolve(repoRoot);
1711
2068
  return createHash("sha256").update(normalized).digest("hex").slice(0, 32);
1712
2069
  }
1713
- let cachedFlyRegion;
1714
- async function detectFlyRegion(baseUrl) {
1715
- if (process.env.RUDDER_CLOUD_REGION) {
1716
- return process.env.RUDDER_CLOUD_REGION.trim().toLowerCase();
2070
+ // Worker placement policy: with the single-region relay in the middle of every
2071
+ // attach, echo latency = RTT(client↔relay) + RTT(relay↔worker), so the worker
2072
+ // belongs NEXT TO THE RELAY, not next to the user. We therefore only send a
2073
+ // region when the user explicitly asked for one (env var or `rudder cloud
2074
+ // region <code>`); otherwise the server places the worker in its own region.
2075
+ async function explicitCloudRegion() {
2076
+ const envRegion = process.env.RUDDER_CLOUD_REGION?.trim().toLowerCase();
2077
+ if (envRegion) {
2078
+ return envRegion;
2079
+ }
2080
+ const state = await loadCloudAuth().catch(() => null);
2081
+ return state?.defaultRegion?.trim().toLowerCase() || undefined;
2082
+ }
2083
+ async function configureRegion(args, options) {
2084
+ const value = (args[0] ?? "").trim().toLowerCase();
2085
+ const state = await loadCloudAuth();
2086
+ if (!state) {
2087
+ throw new Error("Not logged in to Rudder Cloud. Run `rudder login` first.");
1717
2088
  }
1718
- if (cachedFlyRegion) {
1719
- return cachedFlyRegion;
2089
+ if (!value) {
2090
+ const current = state.defaultRegion ?? "";
2091
+ if (options.json) {
2092
+ printJson({ region: current || null });
2093
+ }
2094
+ else if (current) {
2095
+ console.log(`Cloud worker region override: ${current} (run \`rudder cloud region clear\` to let the server choose).`);
2096
+ }
2097
+ else {
2098
+ console.log("No region override set: workers are placed next to the relay for lowest typing latency.");
2099
+ }
2100
+ return;
1720
2101
  }
1721
- try {
1722
- const response = await fetch(`${baseUrl.replace(/\/$/, "")}/health`, { method: "GET" });
1723
- const requestId = response.headers.get("fly-request-id");
1724
- if (requestId) {
1725
- // Fly request-id format: <ulid>-<region>
1726
- const dash = requestId.lastIndexOf("-");
1727
- const region = dash > 0 ? requestId.slice(dash + 1).trim().toLowerCase() : "";
1728
- if (region && region.length <= 6 && /^[a-z]+$/.test(region)) {
1729
- cachedFlyRegion = region;
1730
- return region;
1731
- }
2102
+ if (value === "clear" || value === "none" || value === "auto") {
2103
+ await saveCloudAuth({ ...state, defaultRegion: undefined, updatedAt: nowIso() });
2104
+ if (options.json) {
2105
+ printJson({ ok: true, region: null });
1732
2106
  }
2107
+ else {
2108
+ console.log("Cleared region override; the server will place workers next to the relay.");
2109
+ }
2110
+ return;
1733
2111
  }
1734
- catch {
1735
- // ignore server will fall back to its default region
2112
+ if (!/^[a-z]{3,6}$/.test(value)) {
2113
+ throw new Error(`Invalid Fly region code: ${value}`);
2114
+ }
2115
+ await saveCloudAuth({ ...state, defaultRegion: value, updatedAt: nowIso() });
2116
+ if (options.json) {
2117
+ printJson({ ok: true, region: value });
2118
+ }
2119
+ else {
2120
+ console.log(`Cloud worker region override set to ${value}. Note: placing workers away from the relay increases typing latency.`);
1736
2121
  }
1737
- return undefined;
1738
2122
  }
1739
- async function computeSnapshotFingerprint(repoRoot, _requestedHomePaths) {
2123
+ async function computeSnapshotFingerprint(repoRoot, _requestedHomePaths, vaultActive = false) {
1740
2124
  const hash = createHash("sha256");
1741
2125
  // Repo state: HEAD commit + the porcelain dirty file list. Two attaches
1742
2126
  // from the same repo at the same commit with no edits should produce the
@@ -1750,6 +2134,13 @@ async function computeSnapshotFingerprint(repoRoot, _requestedHomePaths) {
1750
2134
  if (status.code === 0) {
1751
2135
  hash.update(`repo:status:${status.stdout}\n`);
1752
2136
  }
2137
+ // With the vault active the snapshot carries no credentials, so credential
2138
+ // changes must NOT change the fingerprint: a mismatch triggers the
2139
+ // destructive destroy+recreate path server-side, and rotation already takes
2140
+ // effect on the next boot via the supervisor's vault fetch.
2141
+ if (vaultActive) {
2142
+ return hash.digest("hex").slice(0, 32);
2143
+ }
1753
2144
  // macOS Keychain claude credentials: hash content so re-logging in
1754
2145
  // invalidates the cache but a steady-state user keeps it.
1755
2146
  if (process.platform === "darwin") {
@@ -1770,9 +2161,28 @@ async function computeSnapshotFingerprint(repoRoot, _requestedHomePaths) {
1770
2161
  }
1771
2162
  return hash.digest("hex").slice(0, 32);
1772
2163
  }
2164
+ // True when the account has vault secrets on this control plane, meaning
2165
+ // snapshots should stop carrying local credentials. Any failure (old server,
2166
+ // vault unconfigured, network) degrades to legacy snapshot behavior.
2167
+ async function accountHasVaultSecrets(client) {
2168
+ if (process.env.RUDDER_CLOUD_LEGACY_SNAPSHOT_SECRETS === "1") {
2169
+ return false;
2170
+ }
2171
+ try {
2172
+ const result = await client.request("/api/rudder/secrets", { method: "GET" });
2173
+ return (result?.secrets ?? []).length > 0;
2174
+ }
2175
+ catch {
2176
+ return false;
2177
+ }
2178
+ }
1773
2179
  async function workspaceAttach(args, options) {
1774
2180
  const explicitId = args[0];
1775
2181
  if (explicitId) {
2182
+ if (explicitId.includes("/")) {
2183
+ await workspaceAttachByRepo(explicitId, options);
2184
+ return;
2185
+ }
1776
2186
  await workspaceAttachById(explicitId, options);
1777
2187
  return;
1778
2188
  }
@@ -1783,13 +2193,31 @@ async function workspaceAttach(args, options) {
1783
2193
  if (!options.json) {
1784
2194
  process.stderr.write(`Resolving cloud workspace for ${repoName}...\n`);
1785
2195
  }
2196
+ // Prefer an existing cloud-native (clone-based) workspace for this repo's
2197
+ // origin over uploading a snapshot of the local directory.
2198
+ const originSlug = await githubSlugFromOrigin(repoRoot);
2199
+ if (originSlug && isTty() && !options.json) {
2200
+ const cloneWorkspace = await client.request(`/api/rudder/workspace/lookup?repo=${encodeURIComponent(originSlug)}`, { method: "GET" }).catch(() => null);
2201
+ if (cloneWorkspace) {
2202
+ const useClone = await promptConfirm(`A cloud-native workspace for ${originSlug} exists. Attach it instead of uploading a local snapshot?`, true);
2203
+ if (useClone) {
2204
+ const result = await client.request("/api/rudder/workspace/create", {
2205
+ method: "POST",
2206
+ body: { repo: originSlug },
2207
+ });
2208
+ await attachToWorkspaceResult(result, options);
2209
+ return;
2210
+ }
2211
+ }
2212
+ }
1786
2213
  // Kick off the non-interactive work in parallel. planAgentMigration can
1787
2214
  // call promptConfirm for a TTY prompt, so we serialize it AFTER the
1788
2215
  // parallel work resolves to avoid garbled stdout during the prompt.
1789
- const [region, fingerprint] = await Promise.all([
1790
- detectFlyRegion(client.baseUrl).catch(() => undefined),
1791
- computeSnapshotFingerprint(repoRoot, options.homePaths ?? []),
2216
+ const [region, vaultActive] = await Promise.all([
2217
+ explicitCloudRegion(),
2218
+ accountHasVaultSecrets(client),
1792
2219
  ]);
2220
+ const fingerprint = await computeSnapshotFingerprint(repoRoot, options.homePaths ?? [], vaultActive);
1793
2221
  const migrationPlan = await planAgentMigration(repoRoot, options);
1794
2222
  const mustUploadSnapshot = Boolean(migrationPlan && migrationPlan.migrated.length > 0);
1795
2223
  const baseBody = {
@@ -1820,6 +2248,7 @@ async function workspaceAttach(args, options) {
1820
2248
  }
1821
2249
  const snapshot = await createSnapshot(repoRoot, options.homePaths ?? [], {
1822
2250
  includeRudderState: true,
2251
+ includeCredentials: !vaultActive,
1823
2252
  migration: migrationPlan ? { repoName, plan: migrationPlan } : undefined,
1824
2253
  });
1825
2254
  try {
@@ -1891,7 +2320,8 @@ async function attachToWorkspaceResult(result, options) {
1891
2320
  printJson(record);
1892
2321
  return;
1893
2322
  }
1894
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
2323
+ // The latency probe is non-interactive by design; it must not be gated on a TTY.
2324
+ if (!options.latencyProbe && (!process.stdin.isTTY || !process.stdout.isTTY)) {
1895
2325
  process.stderr.write(`Workspace ${workspaceId} is ready. Run \`rudder cloud workspace attach\` from a TTY to take over.\n`);
1896
2326
  return;
1897
2327
  }
@@ -1904,7 +2334,7 @@ async function workspaceAttachById(workspaceId, options) {
1904
2334
  if (options.json) {
1905
2335
  printJson({ id: workspaceId, attaching: true });
1906
2336
  }
1907
- else if (!process.stdin.isTTY || !process.stdout.isTTY) {
2337
+ else if (!options.latencyProbe && (!process.stdin.isTTY || !process.stdout.isTTY)) {
1908
2338
  process.stderr.write(`Workspace ${workspaceId}: attach requires a TTY.\n`);
1909
2339
  return;
1910
2340
  }
@@ -2084,7 +2514,8 @@ async function runAttach(target, options) {
2084
2514
  + `/api/rudder/${target.kind}/${encodeURIComponent(target.id)}/attach`;
2085
2515
  const stdin = process.stdin;
2086
2516
  const stdout = process.stdout;
2087
- const isInteractive = Boolean(stdin.isTTY && stdout.isTTY);
2517
+ const probeMode = Boolean(options.latencyProbe);
2518
+ const isInteractive = Boolean(stdin.isTTY && stdout.isTTY) && !probeMode;
2088
2519
  return await new Promise((resolve, reject) => {
2089
2520
  const socket = new WebSocket(wsUrl, {
2090
2521
  headers: { authorization: `Bearer ${token}` },
@@ -2215,6 +2646,91 @@ async function runAttach(target, options) {
2215
2646
  }
2216
2647
  };
2217
2648
  process.once("SIGINT", onSigint);
2649
+ // --latency-probe state: transport samples resolve on a `probe-reply`
2650
+ // control frame from the worker (pure WS relay round trip); echo samples
2651
+ // resolve on the next binary frame after sending a printable keystroke
2652
+ // (full pipeline including the remote TUI render).
2653
+ const probeReplies = new Map();
2654
+ let probeEchoWaiter = null;
2655
+ let probeSeq = 0;
2656
+ const runLatencyProbe = async () => {
2657
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2658
+ const SAMPLES = 20;
2659
+ const transport = [];
2660
+ const echo = [];
2661
+ // Let the remote dashboard finish its initial redraw burst so spinner
2662
+ // frames don't get mistaken for keystroke echoes.
2663
+ await sleep(750);
2664
+ for (let i = 0; i < SAMPLES; i += 1) {
2665
+ if (socket.readyState !== WebSocket.OPEN)
2666
+ break;
2667
+ const id = ++probeSeq;
2668
+ const sentAt = performance.now();
2669
+ const repliedAt = await new Promise((resolveReply) => {
2670
+ const timer = setTimeout(() => {
2671
+ probeReplies.delete(id);
2672
+ resolveReply(null);
2673
+ }, 2000);
2674
+ probeReplies.set(id, (at) => {
2675
+ clearTimeout(timer);
2676
+ probeReplies.delete(id);
2677
+ resolveReply(at);
2678
+ });
2679
+ socket.send(JSON.stringify({ type: "probe", id }));
2680
+ });
2681
+ if (repliedAt !== null)
2682
+ transport.push(repliedAt - sentAt);
2683
+ await sleep(100);
2684
+ }
2685
+ for (let i = 0; i < SAMPLES; i += 1) {
2686
+ if (socket.readyState !== WebSocket.OPEN)
2687
+ break;
2688
+ const sentAt = performance.now();
2689
+ const echoedAt = await new Promise((resolveFrame) => {
2690
+ const timer = setTimeout(() => {
2691
+ probeEchoWaiter = null;
2692
+ resolveFrame(null);
2693
+ }, 2000);
2694
+ probeEchoWaiter = (at) => {
2695
+ clearTimeout(timer);
2696
+ probeEchoWaiter = null;
2697
+ resolveFrame(at);
2698
+ };
2699
+ socket.send(Buffer.from("a"), { binary: true });
2700
+ });
2701
+ if (echoedAt !== null)
2702
+ echo.push(echoedAt - sentAt);
2703
+ // Undo the probe keystroke so the remote input box is left untouched.
2704
+ socket.send(Buffer.from("\x7f"), { binary: true });
2705
+ await sleep(250);
2706
+ }
2707
+ const stats = (values) => {
2708
+ if (values.length === 0)
2709
+ return "no samples (timed out)";
2710
+ const sorted = [...values].sort((a, b) => a - b);
2711
+ const at = (q) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] ?? 0;
2712
+ const max = sorted[sorted.length - 1] ?? 0;
2713
+ return `p50 ${at(0.5).toFixed(1)}ms p95 ${at(0.95).toFixed(1)}ms max ${max.toFixed(1)}ms (${sorted.length}/${SAMPLES} samples)`;
2714
+ };
2715
+ const report = [
2716
+ "",
2717
+ `Latency probe · ${target.label}`,
2718
+ ` transport RTT ${stats(transport)}`,
2719
+ ` keystroke echo ${stats(echo)}`,
2720
+ "",
2721
+ ].join("\n");
2722
+ if (options.json) {
2723
+ process.stdout.write(`${JSON.stringify({ target: target.label, transportMs: transport, echoMs: echo })}\n`);
2724
+ }
2725
+ else {
2726
+ process.stderr.write(report);
2727
+ }
2728
+ result = "exited";
2729
+ try {
2730
+ socket.close(1000, "probe-done");
2731
+ }
2732
+ catch { /* ignore */ }
2733
+ };
2218
2734
  socket.on("open", () => {
2219
2735
  opened = true;
2220
2736
  // Disable Nagle on the underlying TCP socket so single keystrokes don't
@@ -2263,12 +2779,47 @@ async function runAttach(target, options) {
2263
2779
  // ignore
2264
2780
  }
2265
2781
  }
2266
- stdin.resume();
2267
- stdin.on("data", onStdin);
2782
+ if (!probeMode) {
2783
+ stdin.resume();
2784
+ stdin.on("data", onStdin);
2785
+ }
2786
+ else {
2787
+ // A worker that never boots would otherwise hang the probe forever:
2788
+ // there is no TTY and no human to Ctrl+C it.
2789
+ const firstFrameDeadline = setTimeout(() => {
2790
+ if (!firstFrameRendered) {
2791
+ process.stderr.write("Latency probe timed out waiting for the first remote frame (worker did not boot?).\n");
2792
+ result = "failed";
2793
+ try {
2794
+ socket.close(1000, "probe-timeout");
2795
+ }
2796
+ catch { /* ignore */ }
2797
+ }
2798
+ }, 120_000);
2799
+ firstFrameDeadline.unref?.();
2800
+ }
2268
2801
  stdout.on("resize", onResize);
2269
2802
  });
2270
2803
  socket.on("message", (data, isBinary) => {
2271
2804
  if (isBinary && Buffer.isBuffer(data)) {
2805
+ if (probeMode) {
2806
+ // Frames are timing signals here, not screen content: the first one
2807
+ // marks the dashboard as live (start probing), later ones resolve a
2808
+ // pending keystroke-echo sample.
2809
+ if (!firstFrameRendered) {
2810
+ firstFrameRendered = true;
2811
+ void runLatencyProbe().catch((err) => {
2812
+ process.stderr.write(`Latency probe failed: ${err instanceof Error ? err.message : String(err)}\n`);
2813
+ try {
2814
+ socket.close(1000, "probe-failed");
2815
+ }
2816
+ catch { /* ignore */ }
2817
+ });
2818
+ return;
2819
+ }
2820
+ probeEchoWaiter?.(performance.now());
2821
+ return;
2822
+ }
2272
2823
  if (!firstFrameRendered) {
2273
2824
  firstFrameRendered = true;
2274
2825
  splash?.handoff();
@@ -2317,6 +2868,12 @@ async function runAttach(target, options) {
2317
2868
  return;
2318
2869
  }
2319
2870
  const message = payload;
2871
+ if (message.type === "probe-reply") {
2872
+ if (typeof message.id === "number") {
2873
+ probeReplies.get(message.id)?.(performance.now());
2874
+ }
2875
+ return;
2876
+ }
2320
2877
  if (message.type === "exit") {
2321
2878
  result = message.code === 0 ? "exited" : "failed";
2322
2879
  if (typeof process.exitCode !== "number" && message.code !== undefined) {
@@ -2514,8 +3071,12 @@ Usage:
2514
3071
  print the copy/paste setup for the whole flow
2515
3072
  rudder cloud slack [manifest]
2516
3073
  print Slack setup (one thread per instance in the shared channel)
2517
- rudder cloud workspace [attach [id]|share|status [--json]|pause <id>|resume <id>|stop <id>|list]
2518
- shared cloud workspace for this repo
3074
+ rudder cloud workspace [attach [id|owner/repo]|create <owner/repo>|share|status [--json]|pause <id>|resume <id>|stop <id>|list]
3075
+ shared cloud workspace for this repo; \`create\` clones from GitHub (cloud-native, no local upload)
3076
+ rudder cloud secrets [set <NAME> [value]|set --file <~/path>|list|rm <NAME>|sync]
3077
+ manage the encrypted cloud secrets vault; \`sync\` imports your local credentials once
3078
+ rudder cloud region [<fly-region>|clear]
3079
+ override worker placement (default: next to the relay for lowest typing latency)
2519
3080
  rudder cloud bootstrap <id>
2520
3081
  rudder cloud runtime [fly|byoc]
2521
3082
  rudder cloud setup-byoc <ssh-host> compatibility alias