Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1701,9 +1701,17 @@ Finally, choose what its sessions are visible to, **on the machine itself**:
kcap config set default_visibility org_public
```

Visibility is the machine's own setting, exactly as it is for a person. The
`--visibility` flag on `create` only selects the value printed in the instructions —
it does not configure the runner for you.
Visibility is the machine's own setting, exactly as it is for a person — a machine
records with the `default_visibility` of the profile it runs under, and with no
profile it records `org_public`. It is **not** steered to private. The `--visibility`
flag on `create` only selects the value printed in the setup instructions (defaulting
to your own profile's default, so `create` shows you the value your machine will
actually use) — it does not configure the runner for you.

> **Heads up:** `KCAP_CLIENT_ID`/`KCAP_CLIENT_SECRET` in your environment divert **all**
> of this CLI's auth onto the machine credential, so those variables belong on a runner,
> not in an interactive shell. `kcap status` prints a line naming them when it detects
> them, so a shell that has accidentally inherited them is easy to spot.

Revoking stops a machine authenticating from its next request. A token it already
holds stays valid until it expires (up to an hour) but is no longer honoured. To cut
Expand Down
20 changes: 20 additions & 0 deletions src/Capacitor.Cli.Core/Auth/MachineAuth.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,24 @@ public static class MachineAuth {

return null;
}

/// <summary>
/// The one-line <c>kcap status</c> explanation of the auth diversion <see cref="Intended"/>
/// causes. Returns null when machine auth is not in play (caller prints nothing).
///
/// <para>Distinguishes the two states <see cref="Intended"/> (either-var) collapses, because they
/// are not the same to a reader: with BOTH variables present the CLI genuinely records as the
/// machine instead of the signed-in user; with only ONE the credential is incomplete, so
/// <see cref="TryRead"/> refuses it and NOTHING records — the diversion still happens (the token
/// store is bypassed), so <c>kcap login</c> is not the fix. Saying "records as the machine" in
/// the one-variable case, or letting the profile token-store line then advise <c>kcap login</c>,
/// would both be false. Names exactly which variable(s) are present so the message is truthful in
/// every case.</para>
/// </summary>
public static string? DescribeDiversion(bool idSet, bool secretSet) => (idSet, secretSet) switch {
(false, false) => null,
(true, true) => $"machine credential ({ClientIdVar} and {ClientSecretVar} set) — kcap records as the machine, not as your login.",
(true, false) => $"machine credential incomplete — {ClientIdVar} is set but {ClientSecretVar} is not. Auth is diverted off your login and will fail until both are set.",
(false, true) => $"machine credential incomplete — {ClientSecretVar} is set but {ClientIdVar} is not. Auth is diverted off your login and will fail until both are set.",
};
}
10 changes: 7 additions & 3 deletions src/Capacitor.Cli.Core/Resources/help-machine.txt
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,13 @@ THE SECRET IS SHOWN ONCE
Flags for create:

--visibility <v> What the machine's sessions are visible to. One of:
private, org_public, public. Default: private.
Printed in the setup instructions — see below for why it
is set on the machine rather than here.
private, org_public, public. Defaults to your own
profile's default_visibility — or org_public if you have no
profile, or if your profile's value is one a machine can't
record with (e.g. project, which is member-only and a
machine is never a project member). A machine is not steered
to private. Printed in the setup instructions — see below
for why it is set on the machine rather than here.
--role <role> The machine's role in Capacitor. Default: member.
A machine is never an administrator whatever you pass.

Expand Down
58 changes: 52 additions & 6 deletions src/Capacitor.Cli/Commands/MachineCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Auth;
using Capacitor.Cli.Core.Commands;
using Capacitor.Cli.Core.Config;

namespace Capacitor.Cli.Commands;

Expand Down Expand Up @@ -54,9 +55,15 @@ public static async Task<int> HandleAsync(string baseUrl, string[] args) {
static async Task<int> CreateAsync(string baseUrl, string[] args) {
if (args.Length < 3 || IsHelp(args[2])) return await PrintCreateUsage();

var name = args[2].Trim();
var visibility = GetArg(args, "--visibility") ?? "private";
var role = GetArg(args, "--role");
var name = args[2].Trim();
// The visibility PRINTED in the setup instructions is resolved from the operator's own
// configuration, not steered to private: an explicit flag wins, else this machine records
// with whatever default_visibility the active profile carries (what `kcap setup` wrote),
// else the product default a profile-less runner would use anyway.
var profile = await AppConfig.GetActiveProfileAsync();
var (visibility, visibilityProvenance) =
ResolveCreateVisibility(GetArg(args, "--visibility"), profile?.DefaultVisibility);
var role = GetArg(args, "--role");
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

if (string.IsNullOrWhiteSpace(name)) {
await Console.Error.WriteLineAsync("A machine name is required.");
Expand Down Expand Up @@ -198,7 +205,7 @@ await Console.Error.WriteLineAsync(
return 1;
}

await PrintSetupAsync(registered, provisioned, visibility);
await PrintSetupAsync(registered, provisioned, visibility, visibilityProvenance);

return 0;
}
Expand Down Expand Up @@ -313,7 +320,8 @@ static async Task PrintSecretAsync(string name, string secret, CreateMachineAppl
/// after — and a failure here costs instructions the help can repeat, not a secret.
/// </summary>
static async Task PrintSetupAsync(
RegisterMachineResponse registered, CreateMachineApplicationResponse provisioned, string visibility) {
RegisterMachineResponse registered, CreateMachineApplicationResponse provisioned,
string visibility, string visibilityProvenance) {
await Console.Error.WriteLineAsync();
await Console.Error.WriteLineAsync($"Machine registered as {registered.UserId}. It can now record.");
await Console.Error.WriteLineAsync();
Expand All @@ -322,7 +330,13 @@ static async Task PrintSetupAsync(
await Console.Error.WriteLineAsync($" KCAP_CLIENT_ID={provisioned.ClientId}");
await Console.Error.WriteLineAsync(" KCAP_CLIENT_SECRET=<the secret above>");
await Console.Error.WriteLineAsync();
await Console.Error.WriteLineAsync($" And set what its sessions are visible to (default '{visibility}'):");
// Describe, don't prescribe: the machine records with the default_visibility of the profile
// on the machine it runs on (a machine with no profile records org_public). The value below
// is labeled with where it came from — the flag, the operator's profile, or the product
// default — and never introduces 'private' on this command's own authority.
await Console.Error.WriteLineAsync(
$" Its sessions are visible per the profile it records with ({visibility} — {visibilityProvenance}).");
await Console.Error.WriteLineAsync(" To confirm or change that on the machine itself:");
await Console.Error.WriteLineAsync();
await Console.Error.WriteLineAsync($" kcap config set default_visibility {visibility}");
await Console.Error.WriteLineAsync();
Expand All @@ -332,6 +346,38 @@ await Console.Error.WriteLineAsync(
" machine's own setting, exactly as it is for a person.");
}

/// <summary>
/// The visibility PRINTED in the setup instructions, with a label for where it came from. An
/// explicit <c>--visibility</c> flag wins (validated by the caller — only the flag can be an
/// invalid value); otherwise the active profile's <c>default_visibility</c> when a machine can
/// actually record with it; otherwise the product default <c>org_public</c>, which is what a
/// profile-less runner records with anyway (the server treats an absent
/// <c>default_visibility</c> as <c>org_public</c>).
///
/// <para>A profile may legitimately carry <c>project</c> (a per-viewer, member-only audience) —
/// but a machine is never a project member, so that value is not one it can record with. Rather
/// than inherit it and then reject the whole <c>create</c> with a message that falsely blames a
/// <c>--visibility</c> flag the operator never passed, it falls back to the product default and
/// the provenance says why. So after this resolver the value is always in the machine-valid set
/// UNLESS it came from an explicit flag, which is the only case the caller's validation rejects.</para>
///
/// <para>Deliberately never invents <c>private</c>: it appears only because the flag or the
/// operator's own profile chose it, and the provenance label then says which. Pure so it is
/// unit-tested directly, without the profile/HTTP machinery around it.</para>
/// </summary>
internal static (string Value, string Provenance) ResolveCreateVisibility(
string? flagValue, string? profileDefault) {
if (flagValue is not null) return (flagValue, "from --visibility");

if (profileDefault is not null && Visibilities.Contains(profileDefault, StringComparer.Ordinal))
return (profileDefault, "your profile default");

if (!string.IsNullOrEmpty(profileDefault))
return ("org_public", $"product default; a machine cannot record with your profile's '{profileDefault}' visibility");

return ("org_public", "product default");
}

// ── list ────────────────────────────────────────────────────────────────────────────────────

static async Task<int> ListAsync(string baseUrl) {
Expand Down
43 changes: 28 additions & 15 deletions src/Capacitor.Cli/Commands/StatusCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,24 +38,37 @@ public static async Task<int> HandleAsync(string? baseUrl, string[] args) {
}

// Auth
Console.Write(" Auth: ");
var tokens = await TokenStore.GetValidTokensAsync();
// A machine-credential diversion REPLACES the token-store line rather than appending to it:
// with KCAP_CLIENT_ID/KCAP_CLIENT_SECRET in the environment, MachineAuth.Intended bypasses
// the token store entirely, so its state is not what this CLI authenticates with — printing
// both would show a headless runner as "records as the machine" AND "not authenticated (run:
// kcap login)", contradictory and with irrelevant remediation.
var machineLine = MachineAuth.DescribeDiversion(
!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(MachineAuth.ClientIdVar)),
!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(MachineAuth.ClientSecretVar)));

if (machineLine is not null) {
Console.WriteLine($" Auth: {machineLine}");
} else {
Console.Write(" Auth: ");
var tokens = await TokenStore.GetValidTokensAsync();

if (tokens is not null) {
var remaining = tokens.ExpiresAt - DateTimeOffset.UtcNow;
if (tokens is not null) {
var remaining = tokens.ExpiresAt - DateTimeOffset.UtcNow;

var expiryText = remaining.TotalHours > 1
? $"expires in {remaining.TotalHours:F0}h"
: $"expires in {remaining.TotalMinutes:F0}m";
await Console.Out.WriteLineAsync($"{tokens.GitHubUsername} ({tokens.Provider}) ✓ token valid ({expiryText})");
} else {
var rawTokens = await TokenStore.LoadAsync();
var expiryText = remaining.TotalHours > 1
? $"expires in {remaining.TotalHours:F0}h"
: $"expires in {remaining.TotalMinutes:F0}m";
await Console.Out.WriteLineAsync($"{tokens.GitHubUsername} ({tokens.Provider}) ✓ token valid ({expiryText})");
} else {
var rawTokens = await TokenStore.LoadAsync();

await Console.Out.WriteLineAsync(
rawTokens is not null
? $"{rawTokens.GitHubUsername} ({rawTokens.Provider}) ✗ token expired (run: kcap login)"
: "not authenticated (run: kcap login)"
);
await Console.Out.WriteLineAsync(
rawTokens is not null
? $"{rawTokens.GitHubUsername} ({rawTokens.Provider}) ✗ token expired (run: kcap login)"
: "not authenticated (run: kcap login)"
);
}
}

// Hooks
Expand Down
39 changes: 39 additions & 0 deletions test/Capacitor.Cli.Tests.Unit/MachineAuthStatusLineTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using Capacitor.Cli.Core.Auth;

namespace Capacitor.Cli.Tests.Unit;

/// <summary>
/// `kcap status` warns when machine-credential environment variables are diverting this CLI's auth
/// off the profile token store. Because <see cref="MachineAuth.Intended"/> triggers on EITHER
/// variable but <see cref="MachineAuth.TryRead"/> needs BOTH, the message distinguishes the two:
/// both set means the CLI records as the machine; one set means the credential is incomplete and
/// nothing records (the diversion still bypasses the token store, so `kcap login` is not the fix).
/// It names exactly the variable(s) present and says nothing when neither is set.
/// </summary>
public class MachineAuthStatusLineTests {
[Test]
public async Task Both_variables_present_says_it_records_as_the_machine() {
var line = MachineAuth.DescribeDiversion(idSet: true, secretSet: true);
await Assert.That(line).IsNotNull();
await Assert.That(line!).Contains("KCAP_CLIENT_ID and KCAP_CLIENT_SECRET set");
await Assert.That(line!).Contains("records as the machine, not as your login");
}

[Test]
[Arguments(true, false, "KCAP_CLIENT_ID is set but KCAP_CLIENT_SECRET is not")]
[Arguments(false, true, "KCAP_CLIENT_SECRET is set but KCAP_CLIENT_ID is not")]
public async Task One_variable_present_reports_an_incomplete_credential(bool id, bool secret, string expectedFragment) {
var line = MachineAuth.DescribeDiversion(id, secret);
await Assert.That(line).IsNotNull();
await Assert.That(line!).Contains("incomplete");
await Assert.That(line!).Contains(expectedFragment);
await Assert.That(line!).Contains("diverted");
await Assert.That(line!).DoesNotContain("records as the machine")
.Because("an incomplete credential does not record — TryRead refuses it");
}

[Test]
public async Task Silent_when_neither_variable_is_present() {
await Assert.That(MachineAuth.DescribeDiversion(false, false)).IsNull();
}
}
54 changes: 54 additions & 0 deletions test/Capacitor.Cli.Tests.Unit/MachineCreateVisibilityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using Capacitor.Cli.Commands;

namespace Capacitor.Cli.Tests.Unit;

/// <summary>
/// `kcap machine create` resolves the visibility PRINTED in its setup instructions from the
/// operator's own configuration instead of steering to private: an explicit --visibility flag wins,
/// else the active profile's default_visibility (what `kcap setup` wrote), else the product default
/// org_public for a machine with no profile. Each carries a provenance label so the printed
/// `kcap config set default_visibility ...` line says where its value came from — and `private`
/// only ever appears because the flag or the operator's own profile chose it, never as this
/// command's own suggestion.
/// </summary>
public class MachineCreateVisibilityTests {
[Test]
public async Task Flag_wins_and_is_labeled_as_flag() {
var (value, provenance) = MachineCommand.ResolveCreateVisibility("private", "org_public");
await Assert.That(value).IsEqualTo("private");
await Assert.That(provenance).IsEqualTo("from --visibility");
}

[Test]
public async Task Profile_default_is_inherited_and_labeled() {
var (value, provenance) = MachineCommand.ResolveCreateVisibility(null, "org_public");
await Assert.That(value).IsEqualTo("org_public");
await Assert.That(provenance).IsEqualTo("your profile default");
}

[Test]
public async Task Private_profile_default_is_honored_not_overridden() {
var (value, provenance) = MachineCommand.ResolveCreateVisibility(null, "private");
await Assert.That(value).IsEqualTo("private");
await Assert.That(provenance).IsEqualTo("your profile default");
}

[Test]
public async Task No_profile_falls_back_to_product_default() {
var (value, provenance) = MachineCommand.ResolveCreateVisibility(null, null);
await Assert.That(value).IsEqualTo("org_public");
await Assert.That(provenance).IsEqualTo("product default");
}

[Test]
public async Task Project_profile_default_a_machine_cannot_use_falls_back_without_erroring() {
// 'project' is a valid PROFILE default_visibility (a per-viewer, member-only audience) but a
// machine is never a project member, so it can't record with it. It must fall back to the
// product default rather than inherit a value the create-time validation would then reject
// with a message that falsely blames a --visibility flag the operator never passed.
var (value, provenance) = MachineCommand.ResolveCreateVisibility(null, "project");
await Assert.That(value).IsEqualTo("org_public");
await Assert.That(provenance).Contains("project")
.Because("the operator should see why their profile's value was not used");
}
}
Loading