Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Integration subscription setup, listing and retirement use workspace authentication even when a spawned worker also has an agent token, preventing misleading “Workspace key required” failures.
- A `teams.json` agent whose `cli` carries an inline `--model`/`-m` now records the model the harness actually runs. The inline override becomes the spawn's effective model before the relay skill prefix is chosen, so worker listings, spawn events, telemetry and small-model guidance describe the running model rather than the superseded pin.
- `agent-relay fleet config|enable|disable|inherit` now exit successfully as hidden compatibility no-ops instead of failing on the removed workspace rollout API.
- Targeted `fleet spawn` requests explicit readiness proof, preventing healthy launches from being rejected for missing proof; unconfirmed launches report `ready:false` while obsolete handlers remain rejected.
Expand Down
38 changes: 38 additions & 0 deletions docs/evidence/subscription-live-bindings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[
{
"provider": "github",
"resource": "/github/repos/AgentWorkforce/cloud/pulls/3896/**",
"channel": "agent-events-227666016525594624",
"webhookId": "wh_227668032397963264",
"subscriptionId": "sub_227668035883429888",
"webhookSubscriptionId": "whsub_9171d549-7f7a-4ae5-a340-bc1c01dba11b",
"webhookSubscriptionWorkspaceId": "rw_7ccfea89"
},
{
"provider": "github",
"resource": "/github/repos/AgentWorkforce/relay/pulls/1834/**",
"channel": "agent-events-227666016525594624",
"webhookId": "wh_227674523242795008",
"subscriptionId": "sub_227674526870867968",
"webhookSubscriptionId": "whsub_74bb3bec-bf6e-4953-86af-4c3e1ef7d0b8",
"webhookSubscriptionWorkspaceId": "rw_7ccfea89"
},
{
"provider": "github",
"resource": "/github/repos/AgentWorkforce/relayfile-cloud/pulls/238/**",
"channel": "agent-events-227666016525594624",
"webhookId": "wh_227669931394633728",
"subscriptionId": "sub_227669934972375040",
"webhookSubscriptionId": "whsub_d1a74cda-3a57-45cf-882d-70e24b4d9fb0",
"webhookSubscriptionWorkspaceId": "rw_7ccfea89"
},
{
"provider": "github",
"resource": "/github/repos/AgentWorkforce/relayfile/pulls/515/**",
"channel": "agent-events-227666016525594624",
"webhookId": "wh_227673937327886336",
"subscriptionId": "sub_227673941069205504",
"webhookSubscriptionId": "whsub_45079f6f-a330-46d9-b9b7-b167834cf253",
"webhookSubscriptionWorkspaceId": "rw_7ccfea89"
}
]
11 changes: 11 additions & 0 deletions docs/evidence/subscription-wake-provider.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"id": "3843931114454384640",
"guid": "89e65110-b580-11f1-8407-405863f40b35",
"delivered_at": "2026-09-21T05:51:51.082Z",
"event": "issue_comment",
"action": "created",
"status_code": 200,
"duration": 0.32,
"comment_id": "5755994597",
"repository": "AgentWorkforce/cloud"
}
36 changes: 36 additions & 0 deletions docs/evidence/subscription-workspace-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Provider subscription workspace authentication and wake proof

The resumed worker had real RELAY_WORKSPACE_KEY and RELAY_AGENT_TOKEN values.
A temporary fetch diagnostic recorded GET https://cast.agentrelay.com/v1/agents
returning 401, followed by "Workspace key required (rk_live\*...)". Removing only
RELAY_AGENT_TOKEN made the same request return 200. This was client credential
selection, not missing worker environment, a masked key, or node configuration.
No credential values were logged. Temporary diagnostics were removed.

Integration subscribe/list/unsubscribe (including owned-binding retirement)
require workspace-owner endpoints. They now use createWorkspaceRelay with the
same selected workspace as inbound-target and subscription-channel provisioning.
Explicit --token is rejected with guidance. General agent messaging and other
agent-scoped operations retain createAgentRelay and its ambient-token rules.

Validation: 154 focused CLI/auth tests and CLI typecheck pass. Regression tests
fail before the fix for ambient agent-token setup and explicit-token rejection.
Running the patched source CLI with the original workspace key AND agent token
successfully subscribed webhook-subscription-closeout-r2 to
/github/repos/AgentWorkforce/relayfile/pulls/515/\*\*. The local Relayfile client
used a 120s request budget for this live proof; the deployed 30s default can
still time out on overloaded control-plane operations (separate from auth).

The earlier same-repository Cloud #3896 probe provides the full provider lane:
GitHub comment 5755994597 -> delivery GUID 89e65110-b580-11f1-8407-405863f40b35
-> Relayfile evt_4923952/rev_5368829 -> Relay message 227668215640440832
-> this live agent's next input turn. GitHub delivered at 05:51:51.082Z with
HTTP 200; Relay received at 05:52:12Z (20.918s). Reader confirmation identifies
this owner at 05:56:16Z. The labeled temporary comment was deleted and its
GitHub API returned 404. The provider-side IDs are stored as strings in
subscription-wake-provider.json to preserve integer precision.

This proves issue_comment.created provider subscription delivery and agent
input injection. It does not prove production check_run.completed, fork checks,
or the separate hosted Babysitter flow-listener lane. No merge or deployment
was performed.
42 changes: 40 additions & 2 deletions packages/cli/src/cli/commands/integration-subscribe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,14 +192,17 @@ function harness(
)
)
);
const createWorkspaceRelay = vi.fn(() => relay as never);
const createAgentRelay = vi.fn(() => relay as never);
const log = vi.fn();
const error = vi.fn();
const exit = vi.fn();
const program = new Command();
program.exitOverride();
registerIntegrationCommands(program, {
...opts.recipientDeps,
createAgentRelay: () => relay as never,
createAgentRelay,
createWorkspaceRelay,
relayfile: relayfile as never,
cleanupJournal: journal,
resolveLocalRelayOptions:
Expand All @@ -209,7 +212,7 @@ function harness(
error,
exit: exit as never,
} satisfies Partial<IntegrationCommandDependencies>);
return { program, relay, relayfile, journal, log, error, exit };
return { program, relay, relayfile, journal, log, error, exit, createWorkspaceRelay, createAgentRelay };
}

const RESOURCE = '/slack/channels/C0/**';
Expand Down Expand Up @@ -251,6 +254,41 @@ describe('integration subscribe', () => {
});
});

it('uses workspace auth for subscription management even in an agent-token worker', async () => {
vi.stubEnv('RELAY_WORKSPACE_KEY', 'rk_live_worker_workspace');
vi.stubEnv('RELAY_AGENT_TOKEN', 'at_worker_token');
const { program, createWorkspaceRelay, createAgentRelay, error } = harness({
resolveLocalRelayOptions: async () => undefined,
});
await program.parseAsync(ARGS(), { from: 'user' });
expect(error).not.toHaveBeenCalled();
expect(createWorkspaceRelay).toHaveBeenCalledTimes(1);
expect(createAgentRelay).not.toHaveBeenCalled();
});

it('rejects an explicit agent token for workspace-only subscription operations', async () => {
const { program, relay, error, createWorkspaceRelay } = harness();
await program.parseAsync(ARGS(['--token', 'at_explicit']), { from: 'user' });
expect(error).toHaveBeenCalledWith(expect.stringContaining('requires a workspace key'));
expect(createWorkspaceRelay).not.toHaveBeenCalled();
expect(relay.webhooks.createInbound).not.toHaveBeenCalled();
});

it.each([
['subscribe', 'slack', '--resource', RESOURCE, '--to', '#general'],
['subscribe', '--list'],
['unsubscribe', 'slack', '--resource', RESOURCE],
['unsubscribe', 'slack', '--owned-by', '@lead'],
])('rejects explicit token before provider/control-plane work: %j', async (...args) => {
const relayfile = createRelayfileMock([], { isConnected: vi.fn(async () => false) });
const { program, error } = harness({ relayfile });
await program.parseAsync(['integration', ...args, '--token', 'at_explicit'], { from: 'user' });
expect(error).toHaveBeenCalledWith(expect.stringContaining('requires a workspace key'));
expect(relayfile.ensureCompatible).not.toHaveBeenCalled();
expect(relayfile.isConnected).not.toHaveBeenCalled();
expect(relayfile.connect).not.toHaveBeenCalled();
});

it('resolves provider-native resources before binding and replacement lookup', async () => {
const resolved = '/slack/channels/C123__watchdog-test/**';
const relayfile = createRelayfileMock([], {
Expand Down
31 changes: 27 additions & 4 deletions packages/cli/src/cli/commands/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1528,18 +1528,39 @@ async function runSubscribe(
}
}

/** Subscription provisioning and retirement are workspace-owner operations, like
* their inbound-target and subscription-channel HTTP calls. Do not let the
* worker's ambient participant token override the selected workspace here. */
function validateSubscriptionCredentials(options: SdkClientOptions): void {
if (options.token?.trim()) {
throw new Error(
'Integration subscription management requires a workspace key; use --workspace-key instead of --token.'
);
}
}

function createSubscriptionRelay(
deps: IntegrationCommandDependencies,
options: SdkClientOptions
): AgentRelayAgent {
validateSubscriptionCredentials(options);
return deps.createWorkspaceRelay(options);
}

async function runSubscribeSetup(
deps: IntegrationCommandDependencies,
providerArg: string | undefined,
opts: Record<string, unknown>,
recipient: { launch?: RecipientLaunch; committed?: boolean }
): Promise<void> {
validateSubscriptionCredentials(sdkOptionsFromOpts(opts));
await deps.relayfile.ensureCompatible();

if (opts.list) {
const local = await deps.resolveLocalRelayOptions();
const relayOptions = sdkOptionsFromOpts(opts);
const relay = deps.createAgentRelay(
const relay = createSubscriptionRelay(
deps,
local && !explicitWorkspaceKey(opts) ? localRetryOptions(relayOptions, local) : relayOptions
);
const [bindings, webhooks, subscriptions] = await Promise.all([
Expand Down Expand Up @@ -1572,7 +1593,7 @@ async function runSubscribeSetup(
const relayOptions = sdkOptionsFromOpts(opts);
const effectiveRelayOptions =
local && !explicitWorkspaceKey(opts) ? localRetryOptions(relayOptions, local) : relayOptions;
const relay = deps.createAgentRelay(effectiveRelayOptions);
const relay = createSubscriptionRelay(deps, effectiveRelayOptions);
Comment thread
kjgbot marked this conversation as resolved.
const recipientName = agentName(to);
if (opts.spawn && !recipientName) throw new Error('--spawn requires an explicit @agent recipient');
if (recipientName && typeof opts.spawn === 'string') {
Expand Down Expand Up @@ -1861,12 +1882,13 @@ async function runUnsubscribeOwnedBy(
owner: string,
opts: Record<string, unknown>
): Promise<void> {
validateSubscriptionCredentials(sdkOptionsFromOpts(opts));
await deps.relayfile.ensureCompatible();
const local = await deps.resolveLocalRelayOptions();
const relayOptions = sdkOptionsFromOpts(opts);
const effectiveRelayOptions =
local && !explicitWorkspaceKey(opts) ? localRetryOptions(relayOptions, local) : relayOptions;
const relay = deps.createAgentRelay(effectiveRelayOptions);
const relay = createSubscriptionRelay(deps, effectiveRelayOptions);
const agents = await relay.agents.list();
const agent = agents.find((item) => item.name === owner || `@${item.name}` === owner);
if (!agent) {
Expand Down Expand Up @@ -1907,6 +1929,7 @@ async function runUnsubscribe(
provider: string,
opts: Record<string, unknown>
): Promise<void> {
validateSubscriptionCredentials(sdkOptionsFromOpts(opts));
const ownedBy = typeof opts.ownedBy === 'string' ? opts.ownedBy.trim().replace(/^@/, '') : '';
if (ownedBy) {
await runUnsubscribeOwnedBy(deps, provider, ownedBy, opts);
Expand All @@ -1926,7 +1949,7 @@ async function runUnsubscribe(
const relayOptions = sdkOptionsFromOpts(opts);
const effectiveRelayOptions =
local && !explicitWorkspaceKey(opts) ? localRetryOptions(relayOptions, local) : relayOptions;
const relay = deps.createAgentRelay(effectiveRelayOptions);
const relay = createSubscriptionRelay(deps, effectiveRelayOptions);
const relayScope = relayCleanupScope(effectiveRelayOptions);
const relayfileScope = relayfileCleanupScope();

Expand Down
10 changes: 5 additions & 5 deletions packages/cli/src/cli/commands/relaycast-groups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,7 @@ describe('SDK-backed CLI groups', () => {
const program = new Command();
program.exitOverride();
registerIntegrationCommands(program, {
createAgentRelay: () => relay as never,
createWorkspaceRelay: () => relay as never,
log,
error,
exit: exit as never,
Expand Down Expand Up @@ -617,7 +617,7 @@ describe('SDK-backed CLI groups', () => {
const program = new Command();
program.exitOverride();
registerIntegrationCommands(program, {
createAgentRelay: () => relay as never,
createWorkspaceRelay: () => relay as never,
log: vi.fn(),
error: vi.fn(),
exit: vi.fn() as never,
Expand Down Expand Up @@ -676,7 +676,7 @@ describe('SDK-backed CLI groups', () => {
const program = new Command();
program.exitOverride();
registerIntegrationCommands(program, {
createAgentRelay: () => relay as never,
createWorkspaceRelay: () => relay as never,
log,
error,
exit: exit as never,
Expand Down Expand Up @@ -728,7 +728,7 @@ describe('SDK-backed CLI groups', () => {
const program = new Command();
program.exitOverride();
registerIntegrationCommands(program, {
createAgentRelay: () => relay as never,
createWorkspaceRelay: () => relay as never,
log,
error,
exit: exit as never,
Expand Down Expand Up @@ -793,7 +793,7 @@ describe('SDK-backed CLI groups', () => {
const program = new Command();
program.exitOverride();
registerIntegrationCommands(program, {
createAgentRelay: () => relay as never,
createWorkspaceRelay: () => relay as never,
log,
error,
exit: exit as never,
Expand Down
Loading