From bb0cb3dddd8a42fc8747400c6c96b44fdfae80d3 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:22:10 -0400 Subject: [PATCH 1/5] feat: machine create inherits the profile default visibility with provenance labels kcap machine create no longer defaults its printed default_visibility to private; it resolves from the explicit --visibility flag, else the active profile's default_visibility (what kcap setup wrote), else org_public, each labeled with its provenance. A machine is never steered to private. Co-Authored-By: Claude Fable 5 --- .../Resources/help-machine.txt | 8 ++-- src/Capacitor.Cli/Commands/MachineCommand.cs | 43 ++++++++++++++++--- .../MachineCreateVisibilityTests.cs | 42 ++++++++++++++++++ 3 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 test/Capacitor.Cli.Tests.Unit/MachineCreateVisibilityTests.cs diff --git a/src/Capacitor.Cli.Core/Resources/help-machine.txt b/src/Capacitor.Cli.Core/Resources/help-machine.txt index 034f2742..2f978865 100644 --- a/src/Capacitor.Cli.Core/Resources/help-machine.txt +++ b/src/Capacitor.Cli.Core/Resources/help-machine.txt @@ -29,9 +29,11 @@ THE SECRET IS SHOWN ONCE Flags for create: --visibility 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 (org_public if you have no + profile) — 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 The machine's role in Capacitor. Default: member. A machine is never an administrator whatever you pass. diff --git a/src/Capacitor.Cli/Commands/MachineCommand.cs b/src/Capacitor.Cli/Commands/MachineCommand.cs index cc0caf2d..6f8d72c1 100644 --- a/src/Capacitor.Cli/Commands/MachineCommand.cs +++ b/src/Capacitor.Cli/Commands/MachineCommand.cs @@ -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; @@ -54,9 +55,15 @@ public static async Task HandleAsync(string baseUrl, string[] args) { static async Task 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"); if (string.IsNullOrWhiteSpace(name)) { await Console.Error.WriteLineAsync("A machine name is required."); @@ -198,7 +205,7 @@ await Console.Error.WriteLineAsync( return 1; } - await PrintSetupAsync(registered, provisioned, visibility); + await PrintSetupAsync(registered, provisioned, visibility, visibilityProvenance); return 0; } @@ -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. /// 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(); @@ -322,7 +330,13 @@ static async Task PrintSetupAsync( await Console.Error.WriteLineAsync($" KCAP_CLIENT_ID={provisioned.ClientId}"); await Console.Error.WriteLineAsync(" KCAP_CLIENT_SECRET="); 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(); @@ -332,6 +346,23 @@ await Console.Error.WriteLineAsync( " machine's own setting, exactly as it is for a person."); } + /// + /// The visibility PRINTED in the setup instructions, with a label for where it came from. An + /// explicit --visibility flag wins; otherwise the active profile's + /// default_visibility (what kcap setup wrote — non-null once a profile exists); + /// otherwise the product default org_public, which is what a profile-less runner records + /// with anyway (the server treats an absent default_visibility as org_public). + /// + /// Deliberately never invents private: 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. + /// + internal static (string Value, string Provenance) ResolveCreateVisibility( + string? flagValue, string? profileDefault) => + flagValue is not null ? (flagValue, "from --visibility") + : profileDefault is not null ? (profileDefault, "your profile default") + : ("org_public", "product default"); + // ── list ──────────────────────────────────────────────────────────────────────────────────── static async Task ListAsync(string baseUrl) { diff --git a/test/Capacitor.Cli.Tests.Unit/MachineCreateVisibilityTests.cs b/test/Capacitor.Cli.Tests.Unit/MachineCreateVisibilityTests.cs new file mode 100644 index 00000000..674a3f37 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/MachineCreateVisibilityTests.cs @@ -0,0 +1,42 @@ +using Capacitor.Cli.Commands; + +namespace Capacitor.Cli.Tests.Unit; + +/// +/// `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. +/// +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"); + } +} From 78e49ac8ae4a5c73bef674b55fa0c85c77febb06 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:22:10 -0400 Subject: [PATCH 2/5] feat: kcap status names the machine-auth env vars diverting recording When KCAP_CLIENT_ID/KCAP_CLIENT_SECRET are present the CLI records as the machine, bypassing the token store; kcap status now says so, naming the exact variable(s) set so the secret-only case is not mislabeled. Co-Authored-By: Claude Fable 5 --- src/Capacitor.Cli.Core/Auth/MachineAuth.cs | 16 +++++++++++ src/Capacitor.Cli/Commands/StatusCommand.cs | 8 ++++++ .../MachineAuthStatusLineTests.cs | 27 +++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 test/Capacitor.Cli.Tests.Unit/MachineAuthStatusLineTests.cs diff --git a/src/Capacitor.Cli.Core/Auth/MachineAuth.cs b/src/Capacitor.Cli.Core/Auth/MachineAuth.cs index a3ccd4dd..32911c1c 100644 --- a/src/Capacitor.Cli.Core/Auth/MachineAuth.cs +++ b/src/Capacitor.Cli.Core/Auth/MachineAuth.cs @@ -121,4 +121,20 @@ public static class MachineAuth { return null; } + + /// + /// The one-line kcap status explanation of the auth diversion + /// causes: with either variable present, this CLI records as the machine rather than as the + /// signed-in user, silently bypassing the profile token store. Names exactly the variable(s) + /// present — is either-var, so a fixed "ID is set" line would be false in + /// the secret-only case — and returns null when machine auth is not in play so the caller prints + /// nothing. The trailing clause is what turns a status curiosity into an actionable warning: a + /// developer who exported these into an interactive shell is unknowingly re-owning every session. + /// + public static string? DescribeDiversion(bool idSet, bool secretSet) => (idSet, secretSet) switch { + (false, false) => null, + (true, false) => $"machine credential ({ClientIdVar} is set) — kcap records as the machine, not as your login.", + (false, true) => $"machine credential ({ClientSecretVar} is set) — kcap records as the machine, not as your login.", + (true, true) => $"machine credential ({ClientIdVar} and {ClientSecretVar} are set) — kcap records as the machine, not as your login.", + }; } diff --git a/src/Capacitor.Cli/Commands/StatusCommand.cs b/src/Capacitor.Cli/Commands/StatusCommand.cs index aecab5eb..b22eba4c 100644 --- a/src/Capacitor.Cli/Commands/StatusCommand.cs +++ b/src/Capacitor.Cli/Commands/StatusCommand.cs @@ -38,6 +38,14 @@ public static async Task HandleAsync(string? baseUrl, string[] args) { } // Auth + // Surface a machine-credential diversion first: with KCAP_CLIENT_ID/KCAP_CLIENT_SECRET in + // the environment, this CLI records as the machine and bypasses the token store entirely, so + // the token-store line below would otherwise look inexplicably unauthenticated. + 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}"); + Console.Write(" Auth: "); var tokens = await TokenStore.GetValidTokensAsync(); diff --git a/test/Capacitor.Cli.Tests.Unit/MachineAuthStatusLineTests.cs b/test/Capacitor.Cli.Tests.Unit/MachineAuthStatusLineTests.cs new file mode 100644 index 00000000..1ef02d1e --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/MachineAuthStatusLineTests.cs @@ -0,0 +1,27 @@ +using Capacitor.Cli.Core.Auth; + +namespace Capacitor.Cli.Tests.Unit; + +/// +/// `kcap status` warns when machine-credential environment variables are diverting ALL of this +/// CLI's auth off the profile token store. Because triggers on +/// EITHER variable, the message must name whichever one(s) are actually present — a fixed "ID is +/// set" line would be false in the secret-only case — and say nothing when neither is set. +/// +public class MachineAuthStatusLineTests { + [Test] + [Arguments(true, false, "KCAP_CLIENT_ID is set")] + [Arguments(false, true, "KCAP_CLIENT_SECRET is set")] + [Arguments(true, true, "KCAP_CLIENT_ID and KCAP_CLIENT_SECRET are set")] + public async Task Names_exactly_the_variables_present(bool id, bool secret, string expectedFragment) { + var line = MachineAuth.DescribeDiversion(id, secret); + await Assert.That(line).IsNotNull(); + await Assert.That(line!).Contains(expectedFragment); + await Assert.That(line!).Contains("records as the machine, not as your login"); + } + + [Test] + public async Task Silent_when_neither_variable_is_present() { + await Assert.That(MachineAuth.DescribeDiversion(false, false)).IsNull(); + } +} From 98672dc1c9a024c4f1748abddfa1fbc42ebeabd5 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:30:13 -0400 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20address=20codex=20review=20=E2=80=94?= =?UTF-8?q?=20profile-project=20fallback,=20honest=20incomplete-credential?= =?UTF-8?q?=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ResolveCreateVisibility falls back to org_public when the profile default is a value a machine cannot record with (e.g. 'project'), instead of inheriting it and hitting the machine-only validation with a message that falsely blames --visibility. - DescribeDiversion distinguishes a complete credential (records as the machine) from an incomplete one (one var set: diverted but nothing records). - kcap status prints the machine line INSTEAD of the token-store line when a credential is present, so a runner never shows both 'records as the machine' and 'not authenticated (run: kcap login)'. Co-Authored-By: Claude Fable 5 --- src/Capacitor.Cli.Core/Auth/MachineAuth.cs | 22 ++++++---- src/Capacitor.Cli/Commands/MachineCommand.cs | 31 +++++++++---- src/Capacitor.Cli/Commands/StatusCommand.cs | 43 +++++++++++-------- .../MachineAuthStatusLineTests.cs | 30 +++++++++---- .../MachineCreateVisibilityTests.cs | 12 ++++++ 5 files changed, 93 insertions(+), 45 deletions(-) diff --git a/src/Capacitor.Cli.Core/Auth/MachineAuth.cs b/src/Capacitor.Cli.Core/Auth/MachineAuth.cs index 32911c1c..d36578e9 100644 --- a/src/Capacitor.Cli.Core/Auth/MachineAuth.cs +++ b/src/Capacitor.Cli.Core/Auth/MachineAuth.cs @@ -124,17 +124,21 @@ public static class MachineAuth { /// /// The one-line kcap status explanation of the auth diversion - /// causes: with either variable present, this CLI records as the machine rather than as the - /// signed-in user, silently bypassing the profile token store. Names exactly the variable(s) - /// present — is either-var, so a fixed "ID is set" line would be false in - /// the secret-only case — and returns null when machine auth is not in play so the caller prints - /// nothing. The trailing clause is what turns a status curiosity into an actionable warning: a - /// developer who exported these into an interactive shell is unknowingly re-owning every session. + /// causes. Returns null when machine auth is not in play (caller prints nothing). + /// + /// Distinguishes the two states (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 + /// refuses it and NOTHING records — the diversion still happens (the token + /// store is bypassed), so kcap login is not the fix. Saying "records as the machine" in + /// the one-variable case, or letting the profile token-store line then advise kcap login, + /// would both be false. Names exactly which variable(s) are present so the message is truthful in + /// every case. /// public static string? DescribeDiversion(bool idSet, bool secretSet) => (idSet, secretSet) switch { (false, false) => null, - (true, false) => $"machine credential ({ClientIdVar} is set) — kcap records as the machine, not as your login.", - (false, true) => $"machine credential ({ClientSecretVar} is set) — kcap records as the machine, not as your login.", - (true, true) => $"machine credential ({ClientIdVar} and {ClientSecretVar} are set) — kcap records as the machine, not as your login.", + (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.", }; } diff --git a/src/Capacitor.Cli/Commands/MachineCommand.cs b/src/Capacitor.Cli/Commands/MachineCommand.cs index 6f8d72c1..000d577e 100644 --- a/src/Capacitor.Cli/Commands/MachineCommand.cs +++ b/src/Capacitor.Cli/Commands/MachineCommand.cs @@ -348,20 +348,35 @@ await Console.Error.WriteLineAsync( /// /// The visibility PRINTED in the setup instructions, with a label for where it came from. An - /// explicit --visibility flag wins; otherwise the active profile's - /// default_visibility (what kcap setup wrote — non-null once a profile exists); - /// otherwise the product default org_public, which is what a profile-less runner records - /// with anyway (the server treats an absent default_visibility as org_public). + /// explicit --visibility flag wins (validated by the caller — only the flag can be an + /// invalid value); otherwise the active profile's default_visibility when a machine can + /// actually record with it; otherwise the product default org_public, which is what a + /// profile-less runner records with anyway (the server treats an absent + /// default_visibility as org_public). + /// + /// A profile may legitimately carry project (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 create with a message that falsely blames a + /// --visibility 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. /// /// Deliberately never invents private: 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. /// internal static (string Value, string Provenance) ResolveCreateVisibility( - string? flagValue, string? profileDefault) => - flagValue is not null ? (flagValue, "from --visibility") - : profileDefault is not null ? (profileDefault, "your profile default") - : ("org_public", "product default"); + 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 ──────────────────────────────────────────────────────────────────────────────────── diff --git a/src/Capacitor.Cli/Commands/StatusCommand.cs b/src/Capacitor.Cli/Commands/StatusCommand.cs index b22eba4c..2f38d23e 100644 --- a/src/Capacitor.Cli/Commands/StatusCommand.cs +++ b/src/Capacitor.Cli/Commands/StatusCommand.cs @@ -38,32 +38,37 @@ public static async Task HandleAsync(string? baseUrl, string[] args) { } // Auth - // Surface a machine-credential diversion first: with KCAP_CLIENT_ID/KCAP_CLIENT_SECRET in - // the environment, this CLI records as the machine and bypasses the token store entirely, so - // the token-store line below would otherwise look inexplicably unauthenticated. + // 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}"); - Console.Write(" Auth: "); - var tokens = await TokenStore.GetValidTokensAsync(); + 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 diff --git a/test/Capacitor.Cli.Tests.Unit/MachineAuthStatusLineTests.cs b/test/Capacitor.Cli.Tests.Unit/MachineAuthStatusLineTests.cs index 1ef02d1e..a5f5e548 100644 --- a/test/Capacitor.Cli.Tests.Unit/MachineAuthStatusLineTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/MachineAuthStatusLineTests.cs @@ -3,21 +3,33 @@ namespace Capacitor.Cli.Tests.Unit; /// -/// `kcap status` warns when machine-credential environment variables are diverting ALL of this -/// CLI's auth off the profile token store. Because triggers on -/// EITHER variable, the message must name whichever one(s) are actually present — a fixed "ID is -/// set" line would be false in the secret-only case — and say nothing when neither is set. +/// `kcap status` warns when machine-credential environment variables are diverting this CLI's auth +/// off the profile token store. Because triggers on EITHER +/// variable but 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. /// public class MachineAuthStatusLineTests { [Test] - [Arguments(true, false, "KCAP_CLIENT_ID is set")] - [Arguments(false, true, "KCAP_CLIENT_SECRET is set")] - [Arguments(true, true, "KCAP_CLIENT_ID and KCAP_CLIENT_SECRET are set")] - public async Task Names_exactly_the_variables_present(bool id, bool secret, string expectedFragment) { + 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("records as the machine, not as your login"); + 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] diff --git a/test/Capacitor.Cli.Tests.Unit/MachineCreateVisibilityTests.cs b/test/Capacitor.Cli.Tests.Unit/MachineCreateVisibilityTests.cs index 674a3f37..d133b619 100644 --- a/test/Capacitor.Cli.Tests.Unit/MachineCreateVisibilityTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/MachineCreateVisibilityTests.cs @@ -39,4 +39,16 @@ public async Task No_profile_falls_back_to_product_default() { 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"); + } } From 19bcabb36ff96a4c1dbb0810508c21292e2acc23 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:32:49 -0400 Subject: [PATCH 4/5] =?UTF-8?q?docs:=20README=20=E2=80=94=20machine=20crea?= =?UTF-8?q?te=20inherits=20profile=20default=20visibility;=20note=20kcap?= =?UTF-8?q?=20status=20machine-auth=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses qodo rule violation: user-facing CLI changes must update README. Co-Authored-By: Claude Fable 5 --- README.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 428afa7d..5cc2b68e 100644 --- a/README.md +++ b/README.md @@ -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 From b6933d6402b63ba73e5e71a252c0bfd713db2235 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:35:25 -0400 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20help-machine.txt=20=E2=80=94=20docu?= =?UTF-8?q?ment=20the=20project->org=5Fpublic=20visibility=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 2: the flag-default help implied it always inherits the profile default; note the fallback for a profile value a machine cannot record with. Co-Authored-By: Claude Fable 5 --- src/Capacitor.Cli.Core/Resources/help-machine.txt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Capacitor.Cli.Core/Resources/help-machine.txt b/src/Capacitor.Cli.Core/Resources/help-machine.txt index 2f978865..db477770 100644 --- a/src/Capacitor.Cli.Core/Resources/help-machine.txt +++ b/src/Capacitor.Cli.Core/Resources/help-machine.txt @@ -30,10 +30,12 @@ Flags for create: --visibility What the machine's sessions are visible to. One of: private, org_public, public. Defaults to your own - profile's default_visibility (org_public if you have no - profile) — 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. + 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 The machine's role in Capacitor. Default: member. A machine is never an administrator whatever you pass.