Skip to content

flows: refresh an expired agent-relay cloud login instead of asking for a re-login - #614

Draft
agent-relay-code[bot] wants to merge 3 commits into
mainfrom
relayflow/flows-software-garden-8b26a3c4
Draft

agent-relay-code[bot] wants to merge 3 commits into
mainfrom
relayflow/flows-software-garden-8b26a3c4

Conversation

@agent-relay-code

@agent-relay-code agent-relay-code Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Expired agent-relay access tokens now refresh through /api/v1/auth/token/refresh before Cloud requests, hosted submissions, and mirror resume lookup. The rotated access and refresh tokens are persisted atomically at mode 0600, under the relay-compatible directory lock. Explicit token and FLOWS_CLOUD_TOKEN precedence is preserved, including empty explicit credentials.

The store is re-read under the lock; concurrent callers reuse a completed rotation or post the latest refresh token. Unknown top-level fields survive writes, and all filesystem paths honour AGENT_RELAY_HOME. Refresh requests enforce the existing HTTPS/base-path policy, stay on the issuing deployment, refuse redirects, and produce no stdout. A server-selected base URL must also identify the same deployment.

Credential rejection (400/401/403), malformed responses, transport failures, other HTTP statuses, lock contention, and filesystem errors retain distinct classifications. Persistence failure refuses with auth_store_unwritable, naming the path and errno before any authenticated request uses the new token. Lock acquisition is bounded by five seconds and the request timeout; the shared stale-lock window remains 30 seconds.

This implements the file contract locally because flows has no runtime dependency on @agent-relay/cloud; invoking another CLI would require its binary and couple credential handling to its output. The existing mirror registration catch remains responsible for reporting projection errors without failing a local run. Normal store-backed requests read the small file twice; there is deliberately no cache hiding relay-side rotations.

There is no proactive renewal or renewal triggered by a non-refresh request's 401. Long polling can renew on the first request after the stored expiry. The standalone store reader in workflows/stuck-run-triage.flow.ts is outside this change. No workflow or gate files were edited.

Validation: 280 targeted tests passed, including 44 new store/refresh cases, CLI JSON logs, and hosted submission. Both TypeScript checks passed. The original expired-login refusal cases in cloud-read.test.ts and cloud-deploy.test.ts are unchanged and included in that run. Mutation checks detected both discarded refresh-token rotation and a removed explicit-credential bypass; both source files were restored byte-for-byte and the selected tests passed again.

Full-suite verification remains blocked: npm test stopped in test:prep because rustup has no configured default toolchain. Its Vitest phase did not run. No live credentials or agent-relay cloud whoami were used.

Literal commands and captured output follow. Test commands ran from packages/sdk.

Targeted regression suite
npx vitest run tests/cloud-auth-refresh.test.ts tests/cloud-deploy.test.ts tests/cloud-read.test.ts tests/cloud-run.test.ts tests/cloud-mirror-session.test.ts

 RUN  v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk

 ✓ tests/cloud-read.test.ts (47 tests) 59ms
 ✓ tests/cloud-auth-refresh.test.ts (44 tests) 149ms
 ✓ tests/cloud-mirror-session.test.ts (12 tests) 14ms
 ✓ tests/cloud-run.test.ts (60 tests) 664ms
 ✓ tests/cloud-deploy.test.ts (117 tests) 2078ms

 Test Files  5 passed (5)
      Tests  280 passed (280)
   Start at  10:22:31
   Duration  3.93s (transform 1.26s, setup 50ms, collect 5.01s, tests 2.96s, environment 1ms, prepare 219ms)
TypeScript source and type tests
npm run typecheck

> @relayflows/sdk@2.0.42 typecheck
> tsc --noEmit && tsc -p tsconfig.type-tests.json
TypeScript regression-test compilation
npm run typecheck:tests

> @relayflows/sdk@2.0.42 typecheck:tests
> tsc -p tsconfig.tests.json
Full gate — blocked during setup
npm test

> @relayflows/sdk@2.0.42 test
> sh scripts/test.sh


> @relayflows/sdk@2.0.42 test:prep
> ( cd ../../kernel && sh ../ops/cargo.sh build ) && ( [ ! -d ../../testdata/preflight ] || find ../../testdata/preflight -name '*-cli' -type f -exec chmod +x {} + )

error: rustup could not choose a version of cargo to run, because one wasn't specified explicitly, and no default is configured.
help: run 'rustup default stable' to download the latest stable release of Rust and set it as your default toolchain.

The mutation procedure temporarily replaced refreshToken: payload.refreshToken with refreshToken: login.refreshToken, ran the rotation test, restored the original bytes, and re-ran. It then removed if (explicitCloudToken(options) !== undefined) return;, ran the precedence tests, restored the original bytes, and re-ran. Both mutations exited 1; both restored runs exited 0.

rotation — mutated failure
npx vitest run tests/cloud-auth-refresh.test.ts -t 'persists rotation'

 RUN  v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk

 ❯ tests/cloud-auth-refresh.test.ts (44 tests | 1 failed | 43 skipped) 15ms
   × cloud login refresh > persists rotation atomically in relay format before proceeding, silently 15ms
     → expected { …(6) } to deeply equal { …(6) }

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  tests/cloud-auth-refresh.test.ts > cloud login refresh > persists rotation atomically in relay format before proceeding, silently
AssertionError: expected { …(6) } to deeply equal { …(6) }

- Expected
+ Received

  Object {
    "accessToken": "new-access",
    "accessTokenExpiresAt": "2098-01-01T00:00:00Z",
    "apiUrl": "https://login.example/cloud",
    "futureKey": Object {
      "keep": true,
    },
-   "refreshToken": "new-refresh",
+   "refreshToken": "old-refresh",
    "refreshTokenExpiresAt": "2099-01-01T00:00:00Z",
  }

 ❯ tests/cloud-auth-refresh.test.ts:62:19
     60|     expect(stdout).not.toHaveBeenCalled();
     61|     const saved = JSON.parse(await bytes());
     62|     expect(saved).toEqual({ ...old, ...rotated, futureKey: { keep: tru…
       |                   ^
     63|     expect(relayValid(saved)).toBe(true);
     64|     expect(await bytes()).toBe(`${JSON.stringify(saved, null, 2)}\n`);

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯

 Test Files  1 failed (1)
      Tests  1 failed | 43 skipped (44)
   Start at  10:22:05
   Duration  256ms (transform 71ms, setup 16ms, collect 69ms, tests 15ms, environment 0ms, prepare 45ms)
rotation — restored pass
npx vitest run tests/cloud-auth-refresh.test.ts -t 'persists rotation'

 RUN  v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk

 ✓ tests/cloud-auth-refresh.test.ts (44 tests | 43 skipped) 13ms

 Test Files  1 passed (1)
      Tests  1 passed | 43 skipped (44)
   Start at  10:22:06
   Duration  346ms (transform 138ms, setup 42ms, collect 113ms, tests 13ms, environment 0ms, prepare 44ms)
precedence — mutated failure
npx vitest run tests/cloud-auth-refresh.test.ts -t 'explicit precedence'

 RUN  v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk

 ❯ tests/cloud-auth-refresh.test.ts (44 tests | 4 failed | 40 skipped) 21ms
   × cloud login refresh > explicit precedence bypasses all store operations: token 11ms
     → expected "fetch" to be called 1 times, but got 2 times
   × cloud login refresh > explicit precedence bypasses all store operations: env 2ms
     → expected "fetch" to be called 1 times, but got 2 times
   × cloud login refresh > explicit precedence bypasses all store operations: empty-token 4ms
     → expected "mkdir" to not be called at all, but actually been called 3 times

Received: 

  1st mkdir call:

    Array [
      "/tmp/cloud-refresh-jz4EcY",
      Object {
        "mode": 448,
        "recursive": true,
      },
    ]

  2nd mkdir call:

    Array [
      "/tmp/cloud-refresh-jz4EcY/cloud-auth.json.lock",
      Object {
        "mode": 448,
      },
    ]

  3rd mkdir call:

    Array [
      "/tmp/cloud-refresh-jz4EcY",
      Object {
        "mode": 448,
        "recursive": true,
      },
    ]


Number of calls: 3

   × cloud login refresh > explicit precedence bypasses all store operations: empty-env 2ms
     → expected "mkdir" to not be called at all, but actually been called 3 times

Received: 

  1st mkdir call:

    Array [
      "/tmp/cloud-refresh-td2Oj6",
      Object {
        "mode": 448,
        "recursive": true,
      },
    ]

  2nd mkdir call:

    Array [
      "/tmp/cloud-refresh-td2Oj6/cloud-auth.json.lock",
      Object {
        "mode": 448,
      },
    ]

  3rd mkdir call:

    Array [
      "/tmp/cloud-refresh-td2Oj6",
      Object {
        "mode": 448,
        "recursive": true,
      },
    ]


Number of calls: 3


⎯⎯⎯⎯⎯⎯⎯ Failed Tests 4 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  tests/cloud-auth-refresh.test.ts > cloud login refresh > explicit precedence bypasses all store operations: token
 FAIL  tests/cloud-auth-refresh.test.ts > cloud login refresh > explicit precedence bypasses all store operations: env
AssertionError: expected "fetch" to be called 1 times, but got 2 times
 ❯ tests/cloud-auth-refresh.test.ts:128:21
    126|     else {
    127|       await request(options);
    128|       expect(fetch).toHaveBeenCalledTimes(1);
       |                     ^
    129|       expect(fetch.mock.calls[0]?.[1]?.headers).toMatchObject({ author…
    130|     }

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/4]⎯

 FAIL  tests/cloud-auth-refresh.test.ts > cloud login refresh > explicit precedence bypasses all store operations: empty-token
AssertionError: expected "mkdir" to not be called at all, but actually been called 3 times

Received: 

  1st mkdir call:

    Array [
      "/tmp/cloud-refresh-jz4EcY",
      Object {
        "mode": 448,
        "recursive": true,
      },
    ]

  2nd mkdir call:

    Array [
      "/tmp/cloud-refresh-jz4EcY/cloud-auth.json.lock",
      Object {
        "mode": 448,
      },
    ]

  3rd mkdir call:

    Array [
      "/tmp/cloud-refresh-jz4EcY",
      Object {
        "mode": 448,
        "recursive": true,
      },
    ]


Number of calls: 3

 ❯ tests/cloud-auth-refresh.test.ts:131:23
    129|       expect(fetch.mock.calls[0]?.[1]?.headers).toMatchObject({ author…
    130|     }
    131|     expect(mkdir).not.toHaveBeenCalled();
       |                       ^
    132|     expect(await bytes()).toBe(before);
    133|   });

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/4]⎯

 FAIL  tests/cloud-auth-refresh.test.ts > cloud login refresh > explicit precedence bypasses all store operations: empty-env
AssertionError: expected "mkdir" to not be called at all, but actually been called 3 times

Received: 

  1st mkdir call:

    Array [
      "/tmp/cloud-refresh-td2Oj6",
      Object {
        "mode": 448,
        "recursive": true,
      },
    ]

  2nd mkdir call:

    Array [
      "/tmp/cloud-refresh-td2Oj6/cloud-auth.json.lock",
      Object {
        "mode": 448,
      },
    ]

  3rd mkdir call:

    Array [
      "/tmp/cloud-refresh-td2Oj6",
      Object {
        "mode": 448,
        "recursive": true,
      },
    ]


Number of calls: 3

 ❯ tests/cloud-auth-refresh.test.ts:131:23
    129|       expect(fetch.mock.calls[0]?.[1]?.headers).toMatchObject({ author…
    130|     }
    131|     expect(mkdir).not.toHaveBeenCalled();
       |                       ^
    132|     expect(await bytes()).toBe(before);
    133|   });

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/4]⎯

 Test Files  1 failed (1)
      Tests  4 failed | 40 skipped (44)
   Start at  10:22:06
   Duration  323ms (transform 76ms, setup 14ms, collect 77ms, tests 21ms, environment 0ms, prepare 83ms)
precedence — restored pass
npx vitest run tests/cloud-auth-refresh.test.ts -t 'explicit precedence'

 RUN  v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk

 ✓ tests/cloud-auth-refresh.test.ts (44 tests | 40 skipped) 13ms

 Test Files  1 passed (1)
      Tests  4 passed | 40 skipped (44)
   Start at  10:22:07
   Duration  383ms (transform 153ms, setup 17ms, collect 98ms, tests 13ms, environment 0ms, prepare 98ms)

Unchanged-test comparison, run from the repository root:

python3 - <<'PY'
import subprocess
from pathlib import Path
cases = [
    ('cloud-read.test.ts', 'names the re-login remedy when the stored login has expired, before any request'),
    ('cloud-deploy.test.ts', 'refuses an expired login with the re-login remedy, and a missing store with the configuration message'),
]
for name, title in cases:
    path = 'packages/sdk/tests/' + name
    original = subprocess.check_output(['git', 'show', 'HEAD:' + path], text=True)
    start = original.index("  it('" + title)
    end = original.index('\n  });', start) + len('\n  });')
    assert original[start:end] in Path(path).read_text()
    print(name + ': original expired-login test unchanged')
PY
cloud-read.test.ts: original expired-login test unchanged
cloud-deploy.test.ts: original expired-login test unchanged

Relay contract evidence is from the installed @agent-relay/cloud@12.4.1, not a dependency added to this repository. The following are literal excerpts from that version; line numbers refer to the published dist/ files.

@agent-relay/cloud@12.4.1 dist/types.js:14:

export const DEFAULT_REFRESH_TIMEOUT_MS = 10_000;
export const AUTH_FILE_PATH = path.join(os.homedir(), '.agentworkforce/relay', 'cloud-auth.json');

@agent-relay/cloud@12.4.1 dist/auth.js:14:

const AUTH_DIR_PATH = path.dirname(AUTH_FILE_PATH);
const AUTH_LOCK_PATH = `${AUTH_FILE_PATH}.lock`;
const AUTH_LOCK_RETRY_DELAY_MS = 50;
const AUTH_LOCK_STALE_MS = 30_000;
const AUTH_LOCK_TIMEOUT_MS = 30_000;

@agent-relay/cloud@12.4.1 dist/auth.js:62:

function isValidStoredAuth(value) {
    if (!value || typeof value !== 'object') {
        return false;
    }
    const auth = value;
    return (typeof auth.accessToken === 'string' &&
        typeof auth.refreshToken === 'string' &&
        typeof auth.accessTokenExpiresAt === 'string' &&
        typeof auth.apiUrl === 'string' &&
        (auth.refreshTokenExpiresAt === undefined || typeof auth.refreshTokenExpiresAt === 'string') &&
        !Number.isNaN(Date.parse(auth.accessTokenExpiresAt)) &&
        (auth.refreshTokenExpiresAt === undefined || !Number.isNaN(Date.parse(auth.refreshTokenExpiresAt))));
}

@agent-relay/cloud@12.4.1 dist/auth.js:97:

}
export async function writeStoredAuth(auth) {
    await fs.mkdir(AUTH_DIR_PATH, {
        recursive: true,
        mode: 0o700,
    });
    const temporaryPath = path.join(AUTH_DIR_PATH, `.${path.basename(AUTH_FILE_PATH)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`);
    try {
        await fs.writeFile(temporaryPath, `${JSON.stringify(auth, null, 2)}\n`, {
            encoding: 'utf8',
            mode: 0o600,
        });
        await fs.chmod(temporaryPath, 0o600);
        await fs.rename(temporaryPath, AUTH_FILE_PATH);
    }
    finally {
        await fs.rm(temporaryPath, { force: true });
    }
}

@agent-relay/cloud@12.4.1 dist/auth.js:203:

async function acquireStoredAuthLock(signal) {
    const startedAt = Date.now();
    while (true) {
        if (signal?.aborted) {
            throw signal.reason ?? new Error('Cloud auth lock acquisition aborted');
        }
        try {
            await fs.mkdir(AUTH_LOCK_PATH, { mode: 0o700 });
            return;
        }
        catch (error) {
            if (!isNodeErrorWithCode(error, 'EEXIST')) {
                throw error;
            }
        }
        if (await removeStaleStoredAuthLock()) {
            continue;
        }
        if (Date.now() - startedAt >= AUTH_LOCK_TIMEOUT_MS) {
            throw new Error(`Timed out waiting for cloud auth lock at ${AUTH_LOCK_PATH}`);
        }
        await delay(AUTH_LOCK_RETRY_DELAY_MS, undefined, { signal });
    }
}
async function withStoredAuthLock(callback, options = {}) {
    await fs.mkdir(AUTH_DIR_PATH, {
        recursive: true,
        mode: 0o700,
    });
    await acquireStoredAuthLock(options.signal);
    try {
        return await callback();
    }
    finally {
        await fs.rm(AUTH_LOCK_PATH, { recursive: true, force: true });
    }

@agent-relay/cloud@12.4.1 dist/auth.js:448:

    }
    return withStoredAuthLock(async () => {
        const latestAuth = await readCanonicalStoredAuth();
        const refreshSource = latestAuth?.apiUrl === auth.apiUrl ? latestAuth : auth;
        if (!options.force && latestAuth?.apiUrl === auth.apiUrl && !shouldRefreshStoredAuth(latestAuth)) {
            return latestAuth;
        }
        const nextAuth = await requestStoredAuthRefresh(refreshSource, options);
        // Some credentialed callers impose a stricter transport contract than the
        // general Cloud client. Validate a refresh-selected host before persisting
        // the rotated credentials or allowing a retry to send them there.
        options.validateApiUrl?.(nextAuth.apiUrl);
        await writeStoredAuth(nextAuth);

@agent-relay/cloud@12.4.1 dist/auth.js:464:

async function requestStoredAuthRefresh(auth, options = {}) {
    options.validateApiUrl?.(auth.apiUrl);
    const response = await fetchWithRefreshTimeout(buildApiUrl(auth.apiUrl, '/api/v1/auth/token/refresh'), {
        method: 'POST',
        headers: {
            'content-type': 'application/json',
        },
        body: JSON.stringify({ refreshToken: auth.refreshToken }),
        // An opt-in host policy also refuses HTTP redirects so a 307 cannot
        // replay the refresh token to an unvalidated destination.
        ...(options.validateApiUrl ? { redirect: 'error' } : {}),
    }, options);
    const payload = (await response.json().catch(() => null));
    if (!response.ok || !payload?.accessToken || !payload?.refreshToken || !payload?.accessTokenExpiresAt) {
        throw refreshExpired();
    }
    const nextRefreshTokenExpiresAt = typeof payload.refreshTokenExpiresAt === 'string' && payload.refreshTokenExpiresAt.trim()
        ? payload.refreshTokenExpiresAt.trim()
        : auth.refreshTokenExpiresAt;
    const nextAuth = {
        apiUrl: typeof payload.apiUrl === 'string' && payload.apiUrl.trim() ? payload.apiUrl.trim() : auth.apiUrl,
        accessToken: payload.accessToken,
        refreshToken: payload.refreshToken,
        accessTokenExpiresAt: payload.accessTokenExpiresAt,
        ...(nextRefreshTokenExpiresAt ? { refreshTokenExpiresAt: nextRefreshTokenExpiresAt } : {}),
    };
    return nextAuth;

@agent-relay/cloud@12.4.1 dist/api-client.js:9:

export function buildApiUrl(apiUrl, p) {
    return new URL(trimLeadingSlash(p), withTrailingSlash(apiUrl));
}
function bearerHeaders(headers, accessToken, defaultJson) {

Checks

Relayflow ran this repository's checks (.relayflow/check.sh) and they passed.

What ran (.relayflow/check.sh)
#!/bin/sh
# Fresh-machine check for this repository, mirroring what CI runs.
#
# Primary model: .github/workflows/cloud-runtime-artifact.yml -- the only gate
# that runs the kernel (cargo) and SDK (vitest) suites. It is extended with the
# secret-free parts of .github/workflows/surface-package.yml (the packed-surface
# consumer gate) and .github/workflows/schema-publish.yml (schema codegen and
# parity). There is no Makefile/justfile and no root package.json; every command
# below comes from a workflow file or from a package's own npm/bun scripts.
#
# Deliberately NOT run here, with reasons:
#   * publish.yml's publish jobs and schema-publish.yml's `npm` / `pages` jobs
#     -- npm publishing (NPM_TOKEN / OIDC trusted publishing) and a GitHub Pages
#     deployment. Secrets and deployments.
#   * review-swarm.yml and review-swarm-wrapper-guard.yml -- the review swarm
#     calls `agent-relay cloud run` with CLOUD_API_KEY / RELAY_WORKSPACE_KEY
#     against the hosted relay service, and the guard only means anything in a
#     pull_request_target event. Secrets plus a service this machine lacks.
#   * cloud-runtime-artifact.yml's "Provision hosted extension sandbox" step --
#     it installs bubblewrap as root and writes
#     kernel.apparmor_restrict_unprivileged_userns=0 so unprivileged user
#     namespaces work. In a container that sysctl write is refused ("sysctl:
#     permission denied on key ..."), and with /usr/bin/bwrap present but
#     namespaces still denied the hosted-extension tests fail instead of
#     skipping. The sandbox-dependent test files are therefore skipped or
#     excluded below, which is handled by a capability probe rather than
#     hardcoded, so a machine that CAN unshare runs them exactly like CI.
#   * cloud-runtime-artifact.yml's artifact assembly, Linux-artifact smoke and
#     upload-artifact steps -- release packaging plus an Actions-only upload.
#     Its contract test (scripts/cloud-artifact.test.mjs) does run, first.
#   * `npm test` in packages/sdk -- its test:prep half shells out to
#     ops/cargo.sh, which redirects CARGO_HOME/RUSTUP_HOME/CARGO_TARGET_DIR for
#     a cloud sandbox. CI expands the same sequence against a plain cargo and a
#     RELAYFLOWD_BIN it already built; so does this script.
set -e

repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P)
cd "$repo_root"

# --- toolchain ---------------------------------------------------------------
# CI pins node 22, bun 1.4.0 and rust stable (actions/setup-node,
# oven-sh/setup-bun, dtolnay/rust-toolchain). Node is assumed present; bun is
# pinned below because a test asserts the pin; rust is bootstrapped the way
# ops/cargo.sh does when it finds no cargo, but into the default rustup home so
# plain `cargo` works -- CI's kernel steps use plain cargo on purpose (see the
# long comment on its "Test kernel" step).
if ! command -v cargo >/dev/null 2>&1; then
  if [ -x "$HOME/.cargo/bin/cargo" ]; then
    PATH="$HOME/.cargo/bin:$PATH"
  else
    curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
      | sh -s -- -y --default-toolchain stable --profile minimal --no-modify-path
    PATH="$HOME/.cargo/bin:$PATH"
  fi
  export PATH
fi

# tests/authored-node-runtime.test.ts asserts `bun --version` is exactly 1.4.0
# in its beforeAll -- the standalone build it exercises is compiled by bun, so
# the pin IS part of what that test gates and must not be relaxed. Reproduce
# oven-sh/setup-bun@v2's `bun-version: "1.4.0"` with bun's own installer when
# the bun on PATH is a different build (this machine ships 1.3.6).
bun_pin=1.4.0
if [ "$(bun --version 2>/dev/null)" != "$bun_pin" ]; then
  if [ "$("$HOME/.bun/bin/bun" --version 2>/dev/null)" != "$bun_pin" ]; then
    curl --proto '=https' --tlsv1.2 -fsSL https://bun.sh/install \
      | bash -s "bun-v$bun_pin"
  fi
  PATH="$HOME/.bun/bin:$PATH"
  export PATH
fi

node --version
bun --version
cargo --version

# --- artifact contract (no dependencies required) ----------------------------
node --test scripts/cloud-artifact.test.mjs

# --- kernel ------------------------------------------------------------------
# The release binary is both CI's artifact input and the daemon the SDK's
# live-kernel tests exec through RELAYFLOWD_BIN, so it is built before them.
(cd kernel && cargo build --locked --release -p relayflowd)
(cd kernel && cargo test --workspace)

# --- authoring surface, packed-consumer gate ---------------------------------
# scripts/surface-package-gate.sh is what surface-package.yml runs verbatim: it
# bun-installs and builds the surface, runs its vitest and regression/example
# typechecks, packs it, proves a packed consumer can import it at runtime and at
# type level, then typechecks the SDK against that tarball. It leaves
# packages/sdk/node_modules populated; the SDK section below reinstalls it the
# way cloud-runtime-artifact.yml does.
bash scripts/surface-package-gate.sh

# --- SDK dependencies --------------------------------------------------------
# --ignore-scripts: the surface is already built, and the SDK's own build runs
# below. The local-directory install overrides the registry @relayflows/surface
# that `npm ci` resolves, so an SDK change importing a not-yet-published surface
# symbol still typechecks; --no-save keeps the manifests unchanged. The `./`
# prefix is load-bearing (npm would read "packages/surface" as a GitHub repo).
npm ci --prefix packages/sdk --ignore-scripts
npm install ./packages/surface --prefix packages/sdk --no-save --ignore-scripts

# workflows/*.flow.ts and `flows check` resolve @relayflows/surface from the
# repo root, where nothing is installed; link the same local copy there.
mkdir -p node_modules/@relayflows
ln -sfn ../../packages/sdk/node_modules/@relayflows/surface \
  node_modules/@relayflows/surface
node -e "console.log(require.resolve('@relayflows/surface'))"

# --- schema codegen, must be committed in sync -------------------------------
# schema-publish.yml regenerates, asserts no diff, and asserts the generator is
# deterministic across two runs.
node scripts/generate-json-schema.mjs
git diff --exit-code -- packages/schema/flows.schema.json
cp packages/schema/flows.schema.json "${TMPDIR:-/tmp}/flows.schema.first.json"
node scripts/generate-json-schema.mjs
diff -q "${TMPDIR:-/tmp}/flows.schema.first.json" packages/schema/flows.schema.json
(cd packages/schema && bun run test)

# --- SDK build and suite -----------------------------------------------------
# test:prep's kept half: the preflight CLI fixtures are committed 100755, but
# re-asserting the bit turns an opaque EACCES deep inside a preflight test into
# a non-event.
[ ! -d testdata/preflight ] \
  || find testdata/preflight -name '*-cli' -type f -exec chmod +x {} +

# Eight of those fixtures are extensionless `#!/usr/bin/env node` ESM scripts
# (analyze-story-*-cli, echo-model-cli, tick-slot-report-cli,
# wake-context-probe-cli). Node decides a file's module type from the NEAREST
# ancestor package.json, and with no extension and no package.json inside the
# repo that lookup walks past the checkout. CI checks out under
# /home/runner/work and finds nothing; this machine has
# /home/daytona/package.json declaring "type": "commonjs" ABOVE the checkout,
# so the fixtures load through the CJS loader, their top-level `await` makes the
# graph an async ESM module (ERR_REQUIRE_ASYNC_MODULE), and as a main entry node
# exits 0 having written nothing at all. Every agent-step test that drives one
# then records verification {gate: execution, verdict: fail} with a null output
# instead of the fixture's JSON -- six live-kernel failures with no hint of the
# cause. Restore CI's resolution by declaring the fixture directory ESM for the
# duration of this run; the file is removed on exit and is never committed.
# The directory holds no CommonJS (the other fixtures are /bin/sh, and nothing
# under it calls require()), and flow/project discovery there keys off
# flows.json, not package.json.
preflight_package="$repo_root/testdata/preflight/package.json"
if [ ! -e "$preflight_package" ] \
  && [ -x testdata/preflight/analyze-story-stub-cli ] \
  && [ -z "$(node testdata/preflight/analyze-story-stub-cli 2>/dev/null)" ]; then
  printf '{ "type": "module" }\n' > "$preflight_package"
  trap 'rm -f "$preflight_package"' EXIT HUP INT TERM
  echo "check.sh: an ancestor package.json outside the checkout declares" >&2
  echo "check.sh: \"type\": \"commonjs\"; declaring testdata/preflight ESM for this run" >&2
  # Re-probe, because the failure mode is a silent exit 0: if the fixture still
  # writes nothing the agent-step tests will fail with no usable diagnostic, so
  # say why here instead.
  if [ -z "$(node testdata/preflight/analyze-story-stub-cli 2>&1)" ]; then
    echo "check.sh: preflight agent fixtures still produce no output" >&2
    exit 1
  fi
fi

# Hosted-extension sandbox tests need bubblewrap AND unprivileged user
# namespaces (see the header). Probe with CI's own bwrap invocation and exclude
# only what cannot run. Two tiers, because the guards differ:
#   * hosted-extension-isolation.test.ts and hosted-extension-protocol.test.ts
#     call runVerifiedNativeExtensionSandbox with no guard at all, so they fail
#     with plugin_unsupported ("bubblewrap is unavailable") whenever the sandbox
#     is missing for ANY reason -- bwrap absent included.
#   * babysitter-native-extension.test.ts and
#     software-garden-babysitter-composition.test.ts guard their own sandbox
#     cases with it.skipIf(!existsSync('/usr/bin/bwrap')), which covers bwrap
#     being absent but not bwrap being present and unable to unshare.
# This machine has no /usr/bin/bwrap and cannot create a user namespace
# (`unshare --user` fails EPERM, kernel.apparmor_restrict_unprivileged_userns=1
# and no root to change it), so the first tier is excluded and the second tier
# self-skips. A machine with a working sandbox runs all four exactly like CI.
sandbox_ok=no
if [ -x /usr/bin/bwrap ] \
  && /usr/bin/bwrap --unshare-all --die-with-parent --new-session \
     --ro-bind / / /bin/true >/dev/null 2>&1; then
  sandbox_ok=yes
fi
sandbox_excludes=""
if [ "$sandbox_ok" = no ]; then
  sandbox_excludes="--exclude tests/hosted-extension-isolation.test.ts \
    --exclude tests/hosted-extension-protocol.test.ts"
  if [ -x /usr/bin/bwrap ]; then
    echo "check.sh: /usr/bin/bwrap cannot unshare here; excluding sandbox tests" >&2
    sandbox_excludes="$sandbox_excludes \
      --exclude tests/babysitter-native-extension.test.ts \
      --exclude tests/software-garden-babysitter-composition.test.ts"
  else
    echo "check.sh: no /usr/bin/bwrap; excluding unguarded sandbox tests" >&2
  fi
fi

cd packages/sdk
npm run typecheck
npm run build
npm run typecheck:tests
# RELAYFLOWS_ALLOW_ANALYZER_SKIP=1 exactly as CI sets it: live-kernel.test.ts
# runs one case against the real Claude analyzer and fails closed without it.
# This machine has no `claude` binary and no model access, so that case is
# skipped and says so in its own output -- meaning this run, like CI's, is NOT
# gate-2 acceptance evidence. Everything else in the suite still gates.
RELAYFLOWS_ALLOW_ANALYZER_SKIP=1 \
RELAYFLOWD_BIN="$repo_root/kernel/target/release/relayflowd" \
  ./node_modules/.bin/vitest run $sandbox_excludes

Fixes #464


Note

High Risk
Changes authentication and on-disk credential rotation with locking; bugs could leak tokens, skip refresh, or block Cloud access across processes.

Overview
Expired agent-relay cloud login sessions now renew automatically instead of always refusing with re-login. Before any Cloud HTTP call, the SDK can POST to /api/v1/auth/token/refresh, rotate access and refresh tokens, and atomically persist them under the relay-compatible cloud-auth.json path (mode 0600, directory lock, AGENT_RELAY_HOME). Explicit token / FLOWS_CLOUD_TOKEN still win and never touch the store; renewal is driven by stored access expiry, not a 401 on the main request.

cloud-http gains resolveCloudConnection (used by cloudFetch, runInCloud, and mirror resume lookup) plus a new configuration reason auth_store_unwritable when rotation cannot be written. Docs add the matching cloud_configuration refusal row and updated credential behavior.

Artifact workspace scans no longer fail the whole step when a present file is unreadable (EACCES/EPERM): those paths are signed as size:unreadable:<code> so concurrent bun build --compile temp files do not break neighboring agent steps, while unreadable directories still fail the scan.

Coverage includes a new cloud-auth-refresh suite (lock contention, concurrent refresh, HTTP/transport classification) and CLI/SDK paths that refresh before logs and hosted submission.

Reviewed by Cursor Bugbot for commit 445ee43. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Refreshes an expired agent-relay cloud login on first use so flows no longer require a re-login. Expired access tokens are renewed via /api/v1/auth/token/refresh before Cloud requests, hosted submissions, and mirror resume lookup, with both rotated tokens persisted atomically at mode 0600 under the relay-compatible lock. Explicit token and FLOWS_CLOUD_TOKEN credentials still bypass the store entirely. Failure modes stay distinct: rejected credentials refuse with the re-login remedy, an unwritable store names the path and errno, lock contention is transient, and transport errors keep their classifications.

Also makes the artifact scan tolerate unreadable files: bun build --compile writes its output with mode 000 temporarily, which previously failed neighboring agent steps. Unreadable files are now recorded by size and reason instead of failing the scan.

Written for commit 445ee43. Summary will update on new commits.

Review in cubic

Relayflow and others added 3 commits October 5, 2026 10:23
`.git/info/exclude` lists `/summary.md` alongside `/plan.md` and
`/reviewed-plan.md` as a relayflow working file, so the verification report
was never meant to land in the repository. The file stays on disk; only the
tracked copy goes away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`snapshotWorkspaceFiles` threw on any non-ENOENT error, so an agent step's
artifact scan failed the step whenever a file in the scanned tree could be
seen but not read. `bun build --compile` creates exactly such a file: it
opens its output in its *cwd* with `O_CREAT|O_EXCL` and mode 000, writes the
whole executable into it, and only then renames it onto `--outfile`. With
`bundle-typescript.ts` running that with cwd set to the flow's own directory,
any tree a build is running in holds an unreadable file for as long as the
compile takes — tens of megabytes, seconds — and a neighbouring agent step
that had done its work died on it:

    Error: EACCES: permission denied, open
      '.../packages/sdk/tests/fixtures/.ee96ae00de70a543-00000000.bun-build'
     ❯ walk src/agent-artifacts.ts:68:15
     ❯ Module.snapshotWorkspaceFiles src/agent-artifacts.ts:37:3

An unreadable file is now recorded by size and reason (`7:unreadable:EACCES`)
instead of being dropped or failing the scan, so the `artifacts` list stays
complete and the marker can never be mistaken for a content hash. Everything
else still propagates — an unreadable *directory* included, since a scan that
cannot enumerate a subtree does not know what it is missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 5, 2026

Copy link
Copy Markdown

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 964a7358-0666-4345-b922-9355a07cf9f9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@agent-relay-code
agent-relay-code Bot marked this pull request as draft October 5, 2026 11:30
@agent-relay-code

Copy link
Copy Markdown
Contributor Author

Relayflow: the adversarial review did not pass. This branch is not approved: the flow stopped here and did not mark it ready to merge.

Review of PR #614

Reviewed head: 445ee43a8abf4c30174d621c79d631844456ba6c.
Base: 3c58ee16d10a9e2400db980f5bbafac84e437f20.
PR: #614

Verdict: request changes. One issue remains; review.clean was not created.

P2 — Unreadable-file signatures silently lose same-size artifact edits

Location: packages/sdk/src/agent-artifacts.ts:89 (comparison at line 104).

The unrelated artifact-scanning change replaces a read failure with a signature containing only size and errno. If a file is unreadable at both snapshots, a same-size content rewrite yields identical signatures and diffWorkspaceFiles omits the changed file. A writer can continue writing through an already-open descriptor after permissions become mode 000, including the build-output scenario described in the new comment. This is not limited to Bun temporary files: the fallback applies to every regular file.

worker-cli.ts:158-163 reports this diff as the step's artifacts, so a completed step can silently report an incomplete artifact list and downstream artifact checks can miss a real output. Previously, the unreadable snapshot failed instead of claiming that the content was unchanged. The new test changes file size and therefore does not cover this collision.

Remove this unrelated change from the login-refresh PR, or represent unreadability explicitly and handle it without treating equal size/errno as evidence of unchanged content. Add regression coverage for a same-size write through an open descriptor between two unreadable snapshots.

Reproduction

Executed as UID 1001 (not root). The following script writes aaaa, opens a writable descriptor, makes the file unreadable, snapshots it, writes bbbb through the descriptor, and snapshots again. No production code or existing tests were modified.

Command, run from packages/sdk:

cat > /tmp/pr614-review/artifact-repro.ts <<'EOF'
import { chmod, mkdtemp, open, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { snapshotWorkspaceFiles, diffWorkspaceFiles } from '/home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk/src/agent-artifacts.ts';

const dir = await mkdtemp(join(tmpdir(), 'pr614-artifacts-'));
try {
  const path = join(dir, 'report.txt');
  await writeFile(path, 'aaaa');
  const writer = await open(path, 'r+');
  try {
    await chmod(path, 0o000);
    const before = await snapshotWorkspaceFiles(dir);
    await writer.write('bbbb', 0, 'utf8');
    const after = await snapshotWorkspaceFiles(dir);
    console.log(JSON.stringify({ before: [...before], after: [...after], changed: diffWorkspaceFiles(before, after) }));
  } finally { await writer.close(); }
} finally { await rm(dir, { recursive: true, force: true }); }
EOF
npx vite-node /tmp/pr614-review/artifact-repro.ts

Captured output:

{"before":[["report.txt","4:unreadable:EACCES"]],"after":[["report.txt","4:unreadable:EACCES"]],"changed":[]}

The actual changed: [] demonstrates the omission; the file's contents changed at constant size.

Refresh implementation and review scope

Read the full PR diff, its changed tests, the RFC, and the installed @agent-relay/cloud@12.4.1 implementation at /home/daytona/node_modules/@agent-relay/cloud/dist/auth.js. This installed implementation supplies the store format, atomic-write behavior, refresh response fields, and lock conventions; a sibling relay source checkout was not present. No additional blocking finding identified in the login-refresh implementation. This is a local review, not a live Cloud integration or crash-injection certification.

The refresh tests cover persistence before use, explicit credential precedence, missing/expired refresh tokens, HTTP and transport errors, invalid responses, lock contention, concurrent callers, timeout, and cancellation. Existing artifact tests do not cover the finding above.

Verification evidence

Initial invocation from the repository root could not locate Vitest:

npx vitest run packages/sdk/tests/cloud-auth-refresh.test.ts packages/sdk/tests/cloud-read.test.ts packages/sdk/tests/cloud-run.test.ts packages/sdk/tests/cloud-mirror-session.test.ts packages/sdk/tests/agent-artifacts.test.ts

Captured output:

sh: 1: vitest: not found

Re-ran from packages/sdk, where the dependencies are installed:

npx vitest run tests/cloud-auth-refresh.test.ts tests/cloud-read.test.ts tests/cloud-run.test.ts tests/cloud-mirror-session.test.ts tests/agent-artifacts.test.ts

Captured output:


 RUN  v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk

 ✓ tests/cloud-auth-refresh.test.ts (44 tests) 171ms
 ✓ tests/cloud-mirror-session.test.ts (12 tests) 30ms
 ✓ tests/agent-artifacts.test.ts (10 tests) 22ms
 ✓ tests/cloud-read.test.ts (47 tests) 68ms
 ✓ tests/cloud-run.test.ts (60 tests) 633ms

 Test Files  5 passed (5)
      Tests  173 passed (173)
   Start at  11:28:26
   Duration  2.80s (transform 1.54s, setup 51ms, collect 4.28s, tests 923ms, environment 1ms, prepare 204ms)

Command, from packages/sdk:

npm run typecheck

Captured output:


> @relayflows/sdk@2.0.42 typecheck
> tsc --noEmit && tsc -p tsconfig.type-tests.json

All PR comments and reviews

The only issue comment is CodeRabbit's skipped-review notice. No submitted reviews or inline comments were returned. The comment does not constitute review approval.

Commands and captured output:

gh api --paginate repos/AgentWorkforce/flows/issues/614/comments --jq '.[] | {author: .user.login, url: .html_url, body: .body}'
{"author":"coderabbitai[bot]","body":"\u003c!-- This is an auto-generated comment: summarize by coderabbit.ai --\u003e\n\u003c!-- This is an auto-generated comment: skip review by coderabbit.ai --\u003e\n\n\u003e [!IMPORTANT]\n\u003e ## Review skipped\n\u003e \n\u003e Bot user detected.\n\u003e \n\u003e To trigger a single review, invoke the `@coderabbitai review` command.\n\u003e \n\u003e \u003cdetails\u003e\n\u003e \u003csummary\u003e⚙️ Run configuration\u003c/summary\u003e\n\u003e \n\u003e - **Configuration used**: Organization UI\n\u003e - **Review profile**: CHILL\n\u003e - **Plan**: Advanced\n\u003e - **Run ID**: `964a7358-0666-4345-b922-9355a07cf9f9`\n\u003e \n\u003e \u003c/details\u003e\n\u003e \n\u003e You can disable this status message by setting the `reviews.review_status` to `false` in the CodeRabbit configuration file.\n\u003e \n\u003e Use the checkbox below for a quick retry:\n\u003e - [ ] \u003c!-- {\"checkboxId\":\"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe\"} --\u003e 🔍 Trigger review\n\n\u003c!-- end of auto-generated comment: skip review by coderabbit.ai --\u003e\n\n\u003c!-- autopilot:start --\u003e\n- [ ] \u003c!-- {\"checkboxId\":\"2708ad07-9f24-4260-9c11-7dc76a49f2e3\"} --\u003e \u003cstrong title=\"Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts\"\u003eAutopilot\u003c/strong\u003e · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts\n\u003c!-- autopilot:end --\u003e\n\u003c!-- tips_start --\u003e\n\n---\n\nThanks for using [CodeRabbit](https://coderabbit.ai?utm_source=oss\u0026utm_medium=github\u0026utm_campaign=AgentWorkforce/flows\u0026utm_content=614)! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.\n\n\u003cdetails\u003e\n\u003csummary\u003e❤️ Share\u003c/summary\u003e\n\n- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A\u0026url=https%3A//coderabbit.ai)\n- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)\n- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit\u0026text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)\n- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai\u0026mini=true\u0026title=Great%20tool%20for%20code%20review%20-%20CodeRabbit\u0026summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)\n\n\u003c/details\u003e\n\n\n\u003csub\u003eComment `@coderabbitai help` to get the list of available commands.\u003c/sub\u003e\n\n\u003c!-- tips_end --\u003e","url":"https://github.com/AgentWorkforce/flows/pull/614#issuecomment-5993506891"}
gh api --paginate repos/AgentWorkforce/flows/pulls/614/comments
[]
gh api --paginate repos/AgentWorkforce/flows/pulls/614/reviews
[]

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 445ee43. Configure here.

if (acquired) {
try { await fs.rm(lockPath, { recursive: true, force: true }); }
catch (error) { return { kind: 'unwritable', error, path }; }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finally overrides successful token refresh

Medium Severity

A return in the finally of refreshCloudLogin replaces the try/catch result when lock cleanup fails. A completed persist can surface as auth_store_unwritable, and a cancellation can be swallowed, so the caller refuses instead of using the already-rotated tokens.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 445ee43. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

flows: refresh an expired agent-relay cloud login instead of asking for a re-login

0 participants