feat(credentials): add scoped credential helpers
This commit is contained in:
1 parent
027eaa158a
commit
d808026eec
4 files changed
+207
No files matched your search
@@ -0,0 +1,39 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('Codex', 'Claude', 'Both')][string]$Client,
|
||||
[string]$Key,
|
||||
[switch]$ValueFromStdin,
|
||||
[string]$HomePath = [Environment]::GetFolderPath('UserProfile')
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
. (Join-Path $root 'scripts/lib/install-targets.ps1')
|
||||
$selectedClient = $Client
|
||||
$selectedKey = $Key
|
||||
. (Join-Path $root 'scripts/lib/credentials.ps1')
|
||||
$Client = $selectedClient
|
||||
$Key = $selectedKey
|
||||
|
||||
if (-not $Client) { $Client = Read-InstallClient -Client $Client }
|
||||
if (-not $Key) { $Key = Read-Host 'Credential key' }
|
||||
Assert-CredentialKey $Key
|
||||
if (-not [System.IO.Path]::IsPathRooted($HomePath)) { throw 'HomePath must be absolute.' }
|
||||
if ($ValueFromStdin) {
|
||||
$text = [Console]::In.ReadToEnd().TrimEnd("`r", "`n")
|
||||
$Value = ConvertTo-SecureString $text -AsPlainText -Force
|
||||
$text = $null
|
||||
} else {
|
||||
$Value = Read-Host 'Credential value' -AsSecureString
|
||||
}
|
||||
|
||||
$bin = Join-Path $HomePath '.my-ai-configuration/bin'
|
||||
New-Item -ItemType Directory -Path $bin -Force | Out-Null
|
||||
foreach ($source in @('credentials.ps1', 'with-credential.sh')) {
|
||||
$destination = Join-Path $bin $(if ($source -eq 'credentials.ps1') { 'with-credential.ps1' } else { $source })
|
||||
if (-not (Test-Path -LiteralPath $destination)) { Copy-Item (Join-Path $root "scripts/lib/$source") $destination }
|
||||
}
|
||||
foreach ($target in $(if ($Client -eq 'Both') { @('Codex', 'Claude') } else { @($Client) })) {
|
||||
Set-ManagedCredential $target $Key $Value
|
||||
Write-Output "Credential stored for $($target.ToLowerInvariant())."
|
||||
}
|
||||
$Value = $null
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
client=
|
||||
key=
|
||||
home_path=${HOME:?HOME is required}
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--client) client=${2:?--client requires a value}; shift 2 ;;
|
||||
--key) key=${2:?--key requires a value}; shift 2 ;;
|
||||
--home) home_path=${2:?--home requires a value}; shift 2 ;;
|
||||
*) printf 'Unknown argument: %s\n' "$1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$client" ] || read -r -p 'Select client (codex, claude, or both) ' client
|
||||
[ -n "$key" ] || read -r -p 'Credential key ' key
|
||||
case "$client" in codex|Codex|claude|Claude|both|Both) ;; *) printf 'Unknown --client value: %s\n' "$client" >&2; exit 1 ;; esac
|
||||
case "$key" in [A-Za-z_][A-Za-z0-9_]* ) ;; *) printf 'Credential key must be an environment-variable-compatible name.\n' >&2; exit 1 ;; esac
|
||||
case "$home_path" in /*|[A-Za-z]:[\\/]*) ;; *) printf 'Home path must be absolute.\n' >&2; exit 1 ;; esac
|
||||
|
||||
IFS= read -r -s -p 'Credential value ' value
|
||||
printf '\n'
|
||||
powershell_command=$(command -v powershell.exe || command -v powershell || command -v pwsh || true)
|
||||
[ -n "$powershell_command" ] || { printf 'PowerShell is required to store credentials in the Windows credential store.\n' >&2; exit 1; }
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
printf '%s' "$value" | "$powershell_command" -NoProfile -ExecutionPolicy Bypass -File "$script_dir/add-credentials.ps1" -Client "$client" -Key "$key" -ValueFromStdin -HomePath "$home_path"
|
||||
unset value
|
||||
@@ -0,0 +1,125 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('Codex', 'Claude')][string]$Client,
|
||||
[string]$Key,
|
||||
[Parameter(Position = 2)][string]$FilePath,
|
||||
[Parameter(Position = 3, ValueFromRemainingArguments = $true)][string[]]$ArgumentList
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Assert-CredentialKey {
|
||||
param([string]$Value)
|
||||
if ($Value -notmatch '^[A-Za-z_][A-Za-z0-9_]{0,127}$') { throw 'Credential key must be an environment-variable-compatible name.' }
|
||||
}
|
||||
|
||||
function Get-CredentialTarget {
|
||||
param([string]$Client, [string]$Key)
|
||||
Assert-CredentialKey $Key
|
||||
return "MyAiConfiguration/$($Client.ToLowerInvariant())/$Key"
|
||||
}
|
||||
|
||||
function Initialize-NativeCredentialApi {
|
||||
if ('MyAiConfiguration.NativeCredential' -as [type]) { return }
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
namespace MyAiConfiguration {
|
||||
public static class NativeCredential {
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
public struct CREDENTIAL {
|
||||
public UInt32 Flags;
|
||||
public UInt32 Type;
|
||||
public IntPtr TargetName;
|
||||
public IntPtr Comment;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
|
||||
public UInt32 CredentialBlobSize;
|
||||
public IntPtr CredentialBlob;
|
||||
public UInt32 Persist;
|
||||
public UInt32 AttributeCount;
|
||||
public IntPtr Attributes;
|
||||
public IntPtr TargetAlias;
|
||||
public IntPtr UserName;
|
||||
}
|
||||
[DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
public static extern bool CredWrite(ref CREDENTIAL credential, UInt32 flags);
|
||||
[DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
public static extern bool CredRead(string target, UInt32 type, UInt32 flags, out IntPtr credential);
|
||||
[DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
public static extern bool CredDelete(string target, UInt32 type, UInt32 flags);
|
||||
[DllImport("Advapi32.dll", SetLastError = true)]
|
||||
public static extern void CredFree(IntPtr buffer);
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
|
||||
function Set-ManagedCredential {
|
||||
param([ValidateSet('Codex', 'Claude')][string]$Client, [string]$Key, [securestring]$Value)
|
||||
if (-not $IsWindows -and $PSVersionTable.PSVersion.Major -ge 6) { throw 'The OS credential store is currently supported on Windows only.' }
|
||||
Initialize-NativeCredentialApi
|
||||
$plain = [System.Net.NetworkCredential]::new('', $Value).Password
|
||||
$bytes = [Text.Encoding]::UTF8.GetBytes($plain)
|
||||
if ($bytes.Length -eq 0 -or $bytes.Length -gt 2560) { throw 'Credential value must contain between 1 and 2560 UTF-8 bytes.' }
|
||||
$native = New-Object MyAiConfiguration.NativeCredential+CREDENTIAL
|
||||
$native.Type = 1
|
||||
$native.Persist = 2
|
||||
$native.TargetName = [Runtime.InteropServices.Marshal]::StringToCoTaskMemUni((Get-CredentialTarget $Client $Key))
|
||||
$native.UserName = [Runtime.InteropServices.Marshal]::StringToCoTaskMemUni($Key)
|
||||
$native.CredentialBlobSize = $bytes.Length
|
||||
$native.CredentialBlob = [Runtime.InteropServices.Marshal]::AllocCoTaskMem($bytes.Length)
|
||||
try {
|
||||
[Runtime.InteropServices.Marshal]::Copy($bytes, 0, $native.CredentialBlob, $bytes.Length)
|
||||
if (-not [MyAiConfiguration.NativeCredential]::CredWrite([ref]$native, 0)) { throw [ComponentModel.Win32Exception]::new([Runtime.InteropServices.Marshal]::GetLastWin32Error()) }
|
||||
} finally {
|
||||
[Array]::Clear($bytes, 0, $bytes.Length)
|
||||
$plain = $null
|
||||
if ($native.TargetName -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::FreeCoTaskMem($native.TargetName) }
|
||||
if ($native.UserName -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::FreeCoTaskMem($native.UserName) }
|
||||
if ($native.CredentialBlob -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::FreeCoTaskMem($native.CredentialBlob) }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ManagedCredential {
|
||||
param([ValidateSet('Codex', 'Claude')][string]$Client, [string]$Key)
|
||||
if (-not $IsWindows -and $PSVersionTable.PSVersion.Major -ge 6) { throw 'The OS credential store is currently supported on Windows only.' }
|
||||
Initialize-NativeCredentialApi
|
||||
$pointer = [IntPtr]::Zero
|
||||
if (-not [MyAiConfiguration.NativeCredential]::CredRead((Get-CredentialTarget $Client $Key), 1, 0, [ref]$pointer)) { throw 'Credential was not found.' }
|
||||
try {
|
||||
$native = [Runtime.InteropServices.Marshal]::PtrToStructure($pointer, [type]'MyAiConfiguration.NativeCredential+CREDENTIAL')
|
||||
$bytes = New-Object byte[] $native.CredentialBlobSize
|
||||
[Runtime.InteropServices.Marshal]::Copy($native.CredentialBlob, $bytes, 0, $bytes.Length)
|
||||
try { return [Text.Encoding]::UTF8.GetString($bytes) } finally { [Array]::Clear($bytes, 0, $bytes.Length) }
|
||||
} finally {
|
||||
[MyAiConfiguration.NativeCredential]::CredFree($pointer)
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-ManagedCredential {
|
||||
param([ValidateSet('Codex', 'Claude')][string]$Client, [string]$Key)
|
||||
if (-not $IsWindows -and $PSVersionTable.PSVersion.Major -ge 6) { throw 'The OS credential store is currently supported on Windows only.' }
|
||||
Initialize-NativeCredentialApi
|
||||
if (-not [MyAiConfiguration.NativeCredential]::CredDelete((Get-CredentialTarget $Client $Key), 1, 0)) { throw [ComponentModel.Win32Exception]::new([Runtime.InteropServices.Marshal]::GetLastWin32Error()) }
|
||||
}
|
||||
|
||||
function Invoke-WithManagedCredential {
|
||||
param([ValidateSet('Codex', 'Claude')][string]$Client, [string]$Key, [string]$FilePath, [string[]]$ArgumentList)
|
||||
Assert-CredentialKey $Key
|
||||
if (-not (Test-Path -LiteralPath $FilePath -PathType Leaf)) { throw 'Child command was not found.' }
|
||||
$prior = [Environment]::GetEnvironmentVariable($Key, 'Process')
|
||||
$value = Get-ManagedCredential $Client $Key
|
||||
try {
|
||||
[Environment]::SetEnvironmentVariable($Key, $value, 'Process')
|
||||
& $FilePath @ArgumentList
|
||||
return $LASTEXITCODE
|
||||
} finally {
|
||||
[Environment]::SetEnvironmentVariable($Key, $prior, 'Process')
|
||||
$value = $null
|
||||
}
|
||||
}
|
||||
|
||||
if ($FilePath) {
|
||||
if (-not $Client -or -not $Key) { throw 'Client and key are required.' }
|
||||
exit (Invoke-WithManagedCredential $Client $Key $FilePath $ArgumentList)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
client=${1:?client is required}
|
||||
key=${2:?credential key is required}
|
||||
shift 2
|
||||
[ $# -gt 0 ] || { printf 'Child command is required.\n' >&2; exit 1; }
|
||||
|
||||
case "$client" in codex|Codex) client=Codex ;; claude|Claude) client=Claude ;; *) printf 'Unknown client: %s\n' "$client" >&2; exit 1 ;; esac
|
||||
case "$key" in [A-Za-z_][A-Za-z0-9_]* ) ;; *) printf 'Credential key must be an environment-variable-compatible name.\n' >&2; exit 1 ;; esac
|
||||
|
||||
powershell_command=$(command -v powershell.exe || command -v powershell || command -v pwsh || true)
|
||||
[ -n "$powershell_command" ] || { printf 'PowerShell is required to read the Windows credential store.\n' >&2; exit 1; }
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
exec "$powershell_command" -NoProfile -ExecutionPolicy Bypass -File "$script_dir/with-credential.ps1" -Client "$client" -Key "$key" -FilePath "$1" "${@:2}"
|
||||
Reference in new issue
Block a user