diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2614b36..9ef4b53 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,22 @@
+## 1.51.0
+
+### Changed
+
+- **Git credentials moved to `omnyshell drive credential`** and gained **remote
+ management**. The v1.50.0 `omnyshell node git-credential` command is **removed**;
+ all git-credential management is now under `drive credential`, with the mode chosen
+ explicitly:
+ - **`--local`** — manage credentials on **this node host**
+ (`~/.omnyshell/git-credentials.json`, mode 600), with `--global` (default) or
+ `--for-principal
`; supports `--ssh-key`/`--passphrase`.
+ - **`--node `** — manage **your own** credentials on a **remote** node over the
+ hub (HTTPS only: `--pat`, `--username/--password`). Scoped to the calling principal:
+ the hub stamps the authenticated principal, so a client can only ever read/modify its
+ own credentials on that node — never the global scope or another principal's. Gated by
+ the same authorization as opening a drive on the node.
+ - Subcommands: `add `, `list`, `remove `; remote mode uses the shared
+ connection flags (`--hub`, `-u/--principal`, `-t/--token`, `--key`, `--ca`, …).
+
## 1.50.0
### Added
diff --git a/README.md b/README.md
index 045c89e..185cd80 100644
--- a/README.md
+++ b/README.md
@@ -531,39 +531,48 @@ omnyshell drive mount --git https://github.com/acme/app.git \
```
**Private remotes.** Because the *node* runs the clone/fetch/push, git
-authentication is configured **on the node host**. Register a per-host credential
-there with `omnyshell node git-credential`; it is picked up automatically for any
-git mount whose URL matches that host — no per-mount flags:
+credentials live **on the node host**. Manage them with `omnyshell drive
+credential`; a credential is picked up automatically for any git mount whose URL
+matches that host — no per-mount flags. Choose the target explicitly with
+`--local` (this node host) or `--node ` (a remote node, over the hub).
+
+**Remote — manage _your own_ credentials on a node (HTTPS only):**
```sh
-# On the node host:
-omnyshell node git-credential add github.com --pat # HTTPS PAT
-omnyshell node git-credential add gitlab.com --username me --password
-omnyshell node git-credential add github.com --ssh-key ~/.ssh/id_ed25519
-omnyshell node git-credential list # secrets masked
-omnyshell node git-credential remove github.com
+omnyshell drive credential add github.com --pat --node web-01
+omnyshell drive credential add gitlab.com --username me --password --node web-01
+omnyshell drive credential list --node web-01 # your entries, masked
+omnyshell drive credential remove github.com --node web-01
```
-A credential is either **global** (node-wide) or scoped to a connecting
-**principal** with `--principal `:
+Remote management is **scoped to the calling principal**: the hub stamps your
+authenticated identity, so you can only ever read or modify your own credentials on
+that node — never the global scope or another principal's. It is authorized exactly
+like opening a git drive on that node.
+
+**Local — node-host admin (run on the node), full scope control:**
```sh
-omnyshell node git-credential add github.com --pat # global
-omnyshell node git-credential add github.com --pat --principal alice
-omnyshell node git-credential list --principal alice
-omnyshell node git-credential remove github.com --principal alice
+# On the node host:
+omnyshell drive credential add github.com --pat --local # global (default)
+omnyshell drive credential add github.com --pat --local --for-principal alice
+omnyshell drive credential add github.com --ssh-key ~/.ssh/id_ed25519 --local
+omnyshell drive credential list --local # secrets masked
+omnyshell drive credential remove github.com --local
```
-When a client opens a git mount, the node resolves the remote's credential
-**principal-first, then global**: it uses the connecting (hub-authenticated)
-principal's credential for that host, falling back to the node's global credential
-when the principal has none. One principal never sees another's credential.
+A credential is either **global** (node-wide) or scoped to a **principal**. When a
+client opens a git mount, the node resolves the remote's credential
+**principal-first, then global**, independently per host: it uses the connecting
+(hub-authenticated) principal's credential for that host, falling back to the node's
+global credential when the principal has none. One principal never sees another's.
Credentials are stored in `~/.omnyshell/git-credentials.json` (mode `600`),
separate from the client/hub auth store. They live **only** on the node: they are
never sent to the Hub or peers and never serialized onto a mount (`mounts.json`)
or drive. A missing or wrong credential fails fast rather than hanging on an
-interactive prompt. (SSH keys with a passphrase require an ssh-agent.)
+interactive prompt. (SSH keys with a passphrase require an ssh-agent, and can only
+be set with `--local` since the key path is node-side.)
Restrict which sub-paths a directory mount serves with repeatable `--include`
(whitelist) / `--exclude` (wins over include) globs — the filter is baked into the
diff --git a/bin/omnyshell.dart b/bin/omnyshell.dart
index 795f483..26b5ccf 100644
--- a/bin/omnyshell.dart
+++ b/bin/omnyshell.dart
@@ -1660,7 +1660,6 @@ class NodeCommand extends Command {
NodeCommand() {
addSubcommand(NodeStartCommand());
addSubcommand(NodeProfileCommand());
- addSubcommand(NodeGitCredentialCommand());
}
@override
@@ -1670,51 +1669,130 @@ class NodeCommand extends Command {
String get description => 'Run and manage a Node.';
}
-/// `omnyshell node git-credential` — manage git credentials for private
-/// remotes. These live only on **this node host** (git clone/push runs here),
-/// in `~/.omnyshell/git-credentials.json` (mode 600); they are never sent to the
-/// hub, peers, or serialized onto a drive.
+/// `omnyshell drive credential` — manage git credentials used to clone/pull
+/// private git drives. Two modes, chosen explicitly:
///
-/// A credential is either **global** (node-wide) or scoped to a connecting
-/// **principal** via `--principal `. At mount time the node resolves a host's
-/// credential principal-first, then falls back to the global one.
-class NodeGitCredentialCommand extends Command {
- NodeGitCredentialCommand() {
- addSubcommand(NodeGitCredentialAddCommand());
- addSubcommand(NodeGitCredentialListCommand());
- addSubcommand(NodeGitCredentialRemoveCommand());
+/// * `--local` — manage credentials on **this node host**
+/// (`~/.omnyshell/git-credentials.json`, mode 600), with operator scope
+/// control (`--global`, the default, or `--for-principal `); supports SSH
+/// keys.
+/// * `--node ` — manage **your own** credentials on a remote node, over
+/// the hub (HTTPS only). Scoped to the calling principal: the node stores
+/// them under that principal and never reveals the global scope or another
+/// principal's credentials.
+///
+/// Credentials always live only on the node — never sent to the hub, peers, or
+/// serialized onto a drive. At mount time the node resolves a host's credential
+/// principal-first, then falls back to the global one.
+class DriveCredentialCommand extends Command {
+ DriveCredentialCommand() {
+ addSubcommand(DriveCredentialAddCommand());
+ addSubcommand(DriveCredentialListCommand());
+ addSubcommand(DriveCredentialRemoveCommand());
}
@override
- String get name => 'git-credential';
+ String get name => 'credential';
@override
String get description =>
- 'Manage git credentials this node uses to reach private remotes.';
+ 'Manage git credentials for private git drives (local or remote).';
}
-/// Adds the shared `--principal` option: absent means the global (node-wide)
-/// scope, otherwise the credential is scoped to that connecting principal.
-void _addPrincipalOption(ArgParser parser) => parser.addOption(
- 'principal',
- help: 'Scope to a connecting principal (default: global, node-wide).',
-);
+/// Adds the shared `--local`/`--node` mode selector plus the connection options
+/// used in remote (`--node`) mode.
+void _addCredentialTargetOptions(ArgParser p) {
+ p
+ ..addFlag(
+ 'local',
+ negatable: false,
+ help: 'Manage credentials on THIS node host.',
+ )
+ ..addOption(
+ 'node',
+ help: 'Manage MY credentials on a remote node (over the hub).',
+ );
+ _addConnectionOptions(p);
+}
+
+/// Resolves the target mode, requiring exactly one of `--local` / `--node`.
+({bool local, String? node}) _credentialTarget(ArgResults args) {
+ final local = args['local'] as bool;
+ final node = args['node'] as String?;
+ if (local && node != null) {
+ throw _CliError('Use either --local or --node , not both.');
+ }
+ if (!local && node == null) {
+ throw _CliError(
+ 'Specify --local (this node host) or --node (remote over the hub).',
+ );
+ }
+ return (local: local, node: node);
+}
+
+/// Builds an HTTPS credential (PAT or username/password) from [args].
+GitCredential _httpsCredential(ArgResults args) {
+ final pat = args['pat'] as String?;
+ final username = args['username'] as String?;
+ final password = args['password'] as String?;
+ if (pat != null) {
+ return GitPat(token: pat, username: username ?? 'x-access-token');
+ }
+ if (username != null && password != null) {
+ return GitUserPass(username: username, password: password);
+ }
+ throw _CliError('Provide --pat, or --username with --password.');
+}
+
+/// Builds a credential for local mode, additionally allowing an SSH key.
+GitCredential _localCredential(ArgResults args) {
+ final sshKey = args['ssh-key'] as String?;
+ if (sshKey != null) {
+ return GitSshKey(
+ keyPath: sshKey,
+ passphrase: args['passphrase'] as String?,
+ );
+ }
+ return _httpsCredential(args);
+}
+
+void _printLocalStore(GitCredentialStore store) {
+ for (final host in store.hosts) {
+ stdout.writeln(' $host ${store.get(host)}');
+ }
+}
+
+/// A human-readable scope suffix for local CLI messages.
+String _scopeSuffix(String? principal) =>
+ principal == null ? ' (global)' : ' (principal $principal)';
-class NodeGitCredentialAddCommand extends Command {
- NodeGitCredentialAddCommand() {
+class DriveCredentialAddCommand extends Command {
+ DriveCredentialAddCommand() {
argParser
..addOption('pat', help: 'HTTPS personal access token.')
..addOption(
'username',
- help: 'Username (with --password, or to override the PAT username).',
+ help: 'HTTPS username (with --password, or to override the PAT user).',
)
..addOption('password', help: 'HTTPS password (with --username).')
- ..addOption('ssh-key', help: 'Path to an SSH private key.')
+ ..addOption(
+ 'ssh-key',
+ help: '(--local only) path to an SSH private key on the node host.',
+ )
..addOption(
'passphrase',
- help: 'SSH key passphrase (requires an ssh-agent to take effect).',
+ help: '(--local only) SSH key passphrase (requires an ssh-agent).',
+ )
+ ..addFlag(
+ 'global',
+ negatable: false,
+ help: '(--local) node-wide scope (the default).',
+ )
+ ..addOption(
+ 'for-principal',
+ help: '(--local) scope to a specific principal.',
);
- _addPrincipalOption(argParser);
+ _addCredentialTargetOptions(argParser);
}
@override
@@ -1722,14 +1800,14 @@ class NodeGitCredentialAddCommand extends Command {
@override
String get description =>
- 'Store a credential for a git host (e.g. github.com) on this node.';
+ 'Store a git credential for a host (e.g. github.com).';
@override
String? get usageFooter => _usageExamples([
- 'omnyshell node git-credential add github.com --pat ',
- 'omnyshell node git-credential add gitlab.com --username me --password ',
- 'omnyshell node git-credential add github.com --ssh-key ~/.ssh/id_ed25519',
- 'omnyshell node git-credential add github.com --pat --principal alice',
+ 'omnyshell drive credential add github.com --pat --node web-01',
+ 'omnyshell drive credential add github.com --username me --password --node web-01',
+ 'omnyshell drive credential add github.com --pat --local',
+ 'omnyshell drive credential add github.com --ssh-key ~/.ssh/id_ed25519 --local',
]);
@override
@@ -1738,68 +1816,120 @@ class NodeGitCredentialAddCommand extends Command {
final rest = args.rest;
if (rest.isEmpty) {
throw _CliError(
- 'Usage: omnyshell node git-credential add '
- '(--pat | --username --password | --ssh-key ) '
- '[--principal ]',
+ 'Usage: omnyshell drive credential add '
+ '(--pat | --username --password [| --ssh-key --local]) '
+ '(--local | --node )',
);
}
final host = rest.first;
- final principal = args['principal'] as String?;
- final pat = args['pat'] as String?;
- final username = args['username'] as String?;
- final password = args['password'] as String?;
- final sshKey = args['ssh-key'] as String?;
- final passphrase = args['passphrase'] as String?;
-
- final GitCredential credential;
- if (pat != null) {
- credential = GitPat(token: pat, username: username ?? 'x-access-token');
- } else if (sshKey != null) {
- credential = GitSshKey(keyPath: sshKey, passphrase: passphrase);
- } else if (username != null && password != null) {
- credential = GitUserPass(username: username, password: password);
- } else {
- throw _CliError(
- 'Provide one of: --pat, --ssh-key, or --username with --password.',
- );
+ final target = _credentialTarget(args);
+
+ // Remote: caller-scoped, HTTPS only.
+ if (target.node != null) {
+ if ((args['ssh-key'] as String?) != null) {
+ throw _CliError(
+ '--ssh-key is not supported remotely (its path is node-side); '
+ 'use --local on the node host.',
+ );
+ }
+ if ((args['for-principal'] as String?) != null ||
+ args['global'] as bool) {
+ throw _CliError(
+ '--global/--for-principal apply only to --local; a remote credential '
+ 'is always scoped to your own principal.',
+ );
+ }
+ final credential = _httpsCredential(args);
+ final client = await _connectClient(args);
+ try {
+ final res = await client.driveCredentialAdd(
+ nodeId: target.node!,
+ host: host,
+ credential: credential.toJson(),
+ );
+ if (!res.ok) {
+ throw _CliError(
+ res.message.isEmpty ? 'failed to store credential' : res.message,
+ );
+ }
+ stdout.writeln(
+ res.message.isEmpty
+ ? 'Stored credential for $host on ${target.node}.'
+ : res.message,
+ );
+ } finally {
+ await client.close();
+ }
+ return;
}
+ // Local (node host).
+ final forPrincipal = args['for-principal'] as String?;
+ final credential = _localCredential(args);
final creds = await NodeGitCredentials.load();
- creds.scopeFor(principal: principal).put(host, credential);
+ creds.scopeFor(principal: forPrincipal).put(host, credential);
await creds.save();
- stdout.writeln('Stored $credential for $host${_scopeSuffix(principal)}.');
+ stdout.writeln(
+ 'Stored $credential for $host${_scopeSuffix(forPrincipal)}.',
+ );
}
}
-class NodeGitCredentialListCommand extends Command {
- NodeGitCredentialListCommand() {
- _addPrincipalOption(argParser);
+class DriveCredentialListCommand extends Command {
+ DriveCredentialListCommand() {
+ argParser.addOption(
+ 'for-principal',
+ help: '(--local) show only this principal.',
+ );
+ _addCredentialTargetOptions(argParser);
}
@override
String get name => 'list';
@override
- String get description =>
- 'List git credentials stored on this node (secrets masked).';
+ String get description => 'List git credentials (secrets masked).';
@override
Future run() async {
- final principal = argResults!['principal'] as String?;
- final creds = await NodeGitCredentials.load();
+ final args = argResults!;
+ final target = _credentialTarget(args);
+
+ // Remote: only the caller's own credentials.
+ if (target.node != null) {
+ final client = await _connectClient(args);
+ try {
+ final res = await client.driveCredentialList(nodeId: target.node!);
+ if (!res.ok) {
+ throw _CliError(
+ res.message.isEmpty ? 'failed to list credentials' : res.message,
+ );
+ }
+ if (res.entries.isEmpty) {
+ stdout.writeln('No git credentials.');
+ return;
+ }
+ for (final e in res.entries) {
+ stdout.writeln(' ${e.host} ${e.description}');
+ }
+ } finally {
+ await client.close();
+ }
+ return;
+ }
- // Scoped view: only the requested principal.
- if (principal != null) {
- final store = creds.storeFor(principal);
+ // Local (node host).
+ final forPrincipal = args['for-principal'] as String?;
+ final creds = await NodeGitCredentials.load();
+ if (forPrincipal != null) {
+ final store = creds.storeFor(forPrincipal);
if (store == null || store.hosts.isEmpty) {
- stdout.writeln('No git credentials for principal $principal.');
+ stdout.writeln('No git credentials for principal $forPrincipal.');
return;
}
- _printStore(store);
+ _printLocalStore(store);
return;
}
-
- // Full view: the global section, then each principal section.
final hasGlobal = creds.global.hosts.isNotEmpty;
final principals = creds.principals;
if (!hasGlobal && principals.isEmpty) {
@@ -1808,31 +1938,29 @@ class NodeGitCredentialListCommand extends Command {
}
if (hasGlobal) {
stdout.writeln('[global]');
- _printStore(creds.global);
+ _printLocalStore(creds.global);
}
for (final p in principals) {
stdout.writeln('[principal: $p]');
- _printStore(creds.storeFor(p)!);
- }
- }
-
- void _printStore(GitCredentialStore store) {
- for (final host in store.hosts) {
- stdout.writeln(' $host ${store.get(host)}');
+ _printLocalStore(creds.storeFor(p)!);
}
}
}
-class NodeGitCredentialRemoveCommand extends Command {
- NodeGitCredentialRemoveCommand() {
- _addPrincipalOption(argParser);
+class DriveCredentialRemoveCommand extends Command {
+ DriveCredentialRemoveCommand() {
+ argParser.addOption(
+ 'for-principal',
+ help: '(--local) scope to a specific principal.',
+ );
+ _addCredentialTargetOptions(argParser);
}
@override
String get name => 'remove';
@override
- String get description => 'Remove the stored credential for a git host.';
+ String get description => 'Remove a stored git credential for a host.';
@override
Future run() async {
@@ -1840,27 +1968,59 @@ class NodeGitCredentialRemoveCommand extends Command {
final rest = args.rest;
if (rest.isEmpty) {
throw _CliError(
- 'Usage: omnyshell node git-credential remove [--principal ]',
+ 'Usage: omnyshell drive credential remove (--local | --node )',
);
}
final host = rest.first;
- final principal = args['principal'] as String?;
+ final target = _credentialTarget(args);
+
+ // Remote: caller-scoped.
+ if (target.node != null) {
+ if ((args['for-principal'] as String?) != null) {
+ throw _CliError('--for-principal applies only to --local.');
+ }
+ final client = await _connectClient(args);
+ try {
+ final res = await client.driveCredentialRemove(
+ nodeId: target.node!,
+ host: host,
+ );
+ if (!res.ok) {
+ throw _CliError(
+ res.message.isEmpty
+ ? 'no credential stored for $host'
+ : res.message,
+ );
+ }
+ stdout.writeln(
+ res.message.isEmpty
+ ? 'Removed credential for $host on ${target.node}.'
+ : res.message,
+ );
+ } finally {
+ await client.close();
+ }
+ return;
+ }
+
+ // Local (node host).
+ final forPrincipal = args['for-principal'] as String?;
final creds = await NodeGitCredentials.load();
- final store = principal == null ? creds.global : creds.storeFor(principal);
+ final store = forPrincipal == null
+ ? creds.global
+ : creds.storeFor(forPrincipal);
if (store == null || !store.remove(host)) {
throw _CliError(
- 'No credential stored for $host${_scopeSuffix(principal)}.',
+ 'No credential stored for $host${_scopeSuffix(forPrincipal)}.',
);
}
await creds.save();
- stdout.writeln('Removed credential for $host${_scopeSuffix(principal)}.');
+ stdout.writeln(
+ 'Removed credential for $host${_scopeSuffix(forPrincipal)}.',
+ );
}
}
-/// A human-readable scope suffix for CLI messages.
-String _scopeSuffix(String? principal) =>
- principal == null ? ' (global)' : ' (principal $principal)';
-
/// Resolves the shell whose rc the profile sync reads (and sessions run):
/// `--shell` override, else `$SHELL` (`%COMSPEC%` on Windows).
String _resolveNodeShell(String? override) {
@@ -4985,6 +5145,7 @@ class DriveCommand extends Command {
addSubcommand(DriveResolveCommand());
addSubcommand(DriveUnmountCommand());
addSubcommand(DriveRemountCommand());
+ addSubcommand(DriveCredentialCommand());
}
@override
diff --git a/lib/src/application/client/client_runtime.dart b/lib/src/application/client/client_runtime.dart
index ec740ce..f16145a 100644
--- a/lib/src/application/client/client_runtime.dart
+++ b/lib/src/application/client/client_runtime.dart
@@ -157,6 +157,25 @@ class HubAiConfig {
/// The result of [ClientRuntime.peekSession]: the current screen snapshot of a
/// session, captured without attaching to it.
+/// The result of a `drive credential` RPC against a node.
+class DriveCredentialResult {
+ /// Whether the operation succeeded.
+ final bool ok;
+
+ /// A human-readable result/error message.
+ final String message;
+
+ /// The caller's masked credentials (for `list`; empty otherwise).
+ final List entries;
+
+ /// Creates a drive-credential result.
+ const DriveCredentialResult({
+ required this.ok,
+ required this.message,
+ this.entries = const [],
+ });
+}
+
class SessionScreenResult {
/// Whether a session was found, owned by the caller, and captured.
final bool ok;
@@ -264,6 +283,8 @@ class ClientRuntime {
final Map> _pendingActiveDetach =
{};
final Map> _pendingSessionScreens = {};
+ final Map> _pendingDriveCredentials =
+ {};
final Map<
String,
({Completer completer, String nodeId, int targetPort})
@@ -348,6 +369,16 @@ class ClientRuntime {
altScreen: resp.altScreen,
),
);
+ case final DriveCredentialResponse resp:
+ _pendingDriveCredentials
+ .remove(resp.requestId)
+ ?.complete(
+ DriveCredentialResult(
+ ok: resp.ok,
+ message: resp.message,
+ entries: resp.entries,
+ ),
+ );
case final DetachedSessionKillResponse resp:
_pendingSessionKills
.remove(resp.requestId)
@@ -602,6 +633,53 @@ class ClientRuntime {
return completer.future;
}
+ /// Adds/overwrites the caller's own git credential for [host] on [nodeId].
+ /// [credential] is an omnydrive `GitCredential` JSON map.
+ Future driveCredentialAdd({
+ required String nodeId,
+ required String host,
+ required Map credential,
+ }) => _driveCredential(
+ nodeId: nodeId,
+ op: 'add',
+ host: host,
+ credential: credential,
+ );
+
+ /// Lists the caller's own git credentials on [nodeId] (secrets masked).
+ Future driveCredentialList({required String nodeId}) =>
+ _driveCredential(nodeId: nodeId, op: 'list');
+
+ /// Removes the caller's own git credential for [host] on [nodeId].
+ Future driveCredentialRemove({
+ required String nodeId,
+ required String host,
+ }) => _driveCredential(nodeId: nodeId, op: 'remove', host: host);
+
+ Future _driveCredential({
+ required String nodeId,
+ required String op,
+ String? host,
+ Map? credential,
+ }) {
+ _ensureConnected();
+ final id = newId();
+ final completer = Completer();
+ _pendingDriveCredentials[id] = completer;
+ _connection!.send(
+ ControlFrame(
+ DriveCredentialRequest(
+ requestId: id,
+ nodeId: nodeId,
+ op: op,
+ host: host,
+ credential: credential,
+ ),
+ ),
+ );
+ return completer.future;
+ }
+
/// Detaches one of the caller's *active* sessions on [nodeId] from this
/// connection — used to leave a session whose terminal is busy with a
/// full-screen program. [sessionRef] is a full id, short handle or prefix;
@@ -772,6 +850,12 @@ class ClientRuntime {
}
}
_pendingSessionScreens.clear();
+ for (final completer in _pendingDriveCredentials.values) {
+ if (!completer.isCompleted) {
+ completer.completeError(const TransportException('Disconnected'));
+ }
+ }
+ _pendingDriveCredentials.clear();
for (final pending in _pendingTunnelOpens.values) {
if (!pending.completer.isCompleted) {
pending.completer.completeError(
diff --git a/lib/src/application/hub/hub_broker.dart b/lib/src/application/hub/hub_broker.dart
index 67d1496..be32cb2 100644
--- a/lib/src/application/hub/hub_broker.dart
+++ b/lib/src/application/hub/hub_broker.dart
@@ -406,6 +406,16 @@ class HubBroker {
altScreen: resp.altScreen,
),
);
+ case final NodeDriveCredentialResponse resp:
+ _completeNodeRpc(
+ resp.requestId,
+ DriveCredentialResponse(
+ requestId: resp.requestId,
+ ok: resp.ok,
+ message: resp.message,
+ entries: resp.entries,
+ ),
+ );
case final ChannelExit exit:
_relayNodeToClient(peer, exit.channel, exit);
case final ChannelWindow window:
@@ -472,6 +482,8 @@ class HubBroker {
_handleDetachedSessionKill(peer, req);
case final SessionScreenRequest req:
_handleSessionScreenRequest(peer, req);
+ case final DriveCredentialRequest req:
+ await _handleDriveCredentialRequest(peer, req);
case final ActiveSessionDetachRequest req:
_handleActiveSessionDetach(peer, req);
case final TunnelOpenRequest req:
@@ -711,6 +723,56 @@ class HubBroker {
);
}
+ /// Client → Node: manage the caller's own git credentials on a node. Gated by
+ /// the same authorization as opening a drive session; the principal is stamped
+ /// from the authenticated peer so a client can only ever touch its own scope.
+ Future _handleDriveCredentialRequest(
+ HubPeer peer,
+ DriveCredentialRequest req,
+ ) async {
+ void reply(bool ok, String message) => peer.connection.send(
+ ControlFrame(
+ DriveCredentialResponse(
+ requestId: req.requestId,
+ ok: ok,
+ message: message,
+ ),
+ ),
+ );
+
+ final node = _onlineNode(req.nodeId);
+ if (node == null) {
+ reply(false, 'Node offline');
+ return;
+ }
+ final principal = peer.principal;
+ if (principal == null) {
+ reply(false, 'Not authenticated');
+ return;
+ }
+ final allowed = await authorizer.canOpenSession(
+ principal: principal,
+ node: node.descriptor,
+ mode: SessionMode.drive,
+ );
+ if (!allowed) {
+ reply(false, 'Not authorized');
+ return;
+ }
+ _pendingNodeRpc[req.requestId] = peer;
+ node.peer.connection.send(
+ ControlFrame(
+ NodeDriveCredentialRequest(
+ requestId: req.requestId,
+ principal: principal.id.value,
+ op: req.op,
+ host: req.host,
+ credential: req.credential,
+ ),
+ ),
+ );
+ }
+
/// Client → Node: kill one of the caller's detached sessions on a node.
void _handleDetachedSessionKill(
HubPeer peer,
diff --git a/lib/src/application/node/node_runtime.dart b/lib/src/application/node/node_runtime.dart
index 894a3e7..096f3d1 100644
--- a/lib/src/application/node/node_runtime.dart
+++ b/lib/src/application/node/node_runtime.dart
@@ -2,7 +2,8 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
-import 'package:omnydrive/omnydrive.dart' show GitCredentialResolver;
+import 'package:omnydrive/omnydrive.dart'
+ show GitCredential, GitCredentialResolver;
import '../../domain/backend/shell_backend.dart';
import '../../domain/backend/shell_request.dart';
@@ -121,6 +122,11 @@ class NodeConfig {
/// resolved path is not under one of these roots.
final List driveRoots;
+ /// Overrides the home directory used to load/save this node's git credentials
+ /// (`/.omnyshell/git-credentials.json`). `null` uses the process default
+ /// ([omnyshellHome]). Mainly for tests and multi-node isolation.
+ final String? gitCredentialsHome;
+
/// Whether a client connection that drops (network loss, crash, terminal
/// closed) automatically detaches its session — keeping the PTY, shell and
/// child processes alive for a later resume — instead of terminating it.
@@ -158,6 +164,7 @@ class NodeConfig {
this.driveEnabled = true,
this.tunnelEnabled = true,
this.driveRoots = const [],
+ this.gitCredentialsHome,
this.autoDetachOnDisconnect = true,
this.autoDetachTimeout,
this.cleanupInterval = const Duration(minutes: 1),
@@ -302,6 +309,8 @@ class NodeRuntime {
await _onKillSession(req);
case final NodeSessionScreenRequest req:
_onScreenRequest(req);
+ case final NodeDriveCredentialRequest req:
+ await _onDriveCredentialRequest(req);
case final NodeActiveSessionDetach req:
await _onDetachActive(req);
case final Ping ping:
@@ -431,7 +440,9 @@ class NodeRuntime {
// must never block a session, so failures degrade to no credentials.
GitCredentialResolver? gitCreds;
try {
- final creds = await NodeGitCredentials.load();
+ final creds = await NodeGitCredentials.load(
+ home: config.gitCredentialsHome,
+ );
gitCreds = creds.resolverFor(open.principal);
} on Object catch (e) {
config.logger?.call('[drive] ignoring git credentials: $e');
@@ -931,6 +942,80 @@ class NodeRuntime {
/// Returns the current screen snapshot of one of the caller's sessions —
/// *running* (attached) or detached — named by [req.sessionRef], without
/// disturbing it. The replayed bytes are exactly what a resume would paint.
+ // Serializes drive-credential RPCs so concurrent load/mutate/save never race.
+ Future _credChain = Future.value();
+
+ /// Hub → Node: manage the caller's own git credentials. [req.principal] is
+ /// hub-stamped, so this only ever touches that principal's scope — never the
+ /// global scope and never a client-supplied principal.
+ Future _onDriveCredentialRequest(NodeDriveCredentialRequest req) {
+ return _credChain = _credChain.then((_) => _applyDriveCredential(req));
+ }
+
+ Future _applyDriveCredential(NodeDriveCredentialRequest req) async {
+ final home = config.gitCredentialsHome;
+ var ok = false;
+ var message = '';
+ var entries = const [];
+ try {
+ if (!config.driveEnabled) {
+ message = 'git credential management is disabled on this node';
+ } else {
+ final creds = await NodeGitCredentials.load(home: home);
+ switch (req.op) {
+ case 'add':
+ final host = req.host;
+ final credJson = req.credential;
+ if (host == null || credJson == null) {
+ message = 'host and credential are required';
+ break;
+ }
+ creds
+ .scopeFor(principal: req.principal)
+ .put(host, GitCredential.fromJson(credJson));
+ await creds.save(home: home);
+ ok = true;
+ message = 'stored credential for $host';
+ case 'remove':
+ final host = req.host;
+ final store = creds.storeFor(req.principal);
+ if (host == null) {
+ message = 'host is required';
+ } else if (store == null || !store.remove(host)) {
+ message = 'no credential stored for $host';
+ } else {
+ await creds.save(home: home);
+ ok = true;
+ message = 'removed credential for $host';
+ }
+ case 'list':
+ final store = creds.storeFor(req.principal);
+ entries = [
+ if (store != null)
+ for (final h in store.hosts)
+ DriveCredentialEntry(host: h, description: '${store.get(h)}'),
+ ];
+ ok = true;
+ default:
+ message = 'unknown op: ${req.op}';
+ }
+ }
+ } on Object catch (e) {
+ ok = false;
+ message = '$e';
+ }
+ _connection?.send(
+ ControlFrame(
+ NodeDriveCredentialResponse(
+ requestId: req.requestId,
+ ok: ok,
+ message: message,
+ entries: entries,
+ ),
+ ),
+ );
+ }
+
void _onScreenRequest(NodeSessionScreenRequest req) {
// Active (attached) shell sessions owned by the caller that match the ref.
final activeMatches = _sessions.values
diff --git a/lib/src/protocol/control_message.dart b/lib/src/protocol/control_message.dart
index c8bdb7e..6609238 100644
--- a/lib/src/protocol/control_message.dart
+++ b/lib/src/protocol/control_message.dart
@@ -2104,6 +2104,246 @@ final class SessionScreenResponse extends ControlMessage {
);
}
+/// One masked git-credential entry returned by a `list` (secrets never leave the
+/// node — [description] is the credential's masked `toString`).
+class DriveCredentialEntry {
+ /// The git host (e.g. `github.com`).
+ final String host;
+
+ /// The masked, human-readable credential description.
+ final String description;
+
+ const DriveCredentialEntry({required this.host, required this.description});
+
+ Map toJson() => {'host': host, 'description': description};
+
+ static DriveCredentialEntry fromJson(Map d) =>
+ DriveCredentialEntry(
+ host: Json.requireString(d, 'host'),
+ description: Json.requireString(d, 'description'),
+ );
+}
+
+List _decodeCredEntries(Object? raw) {
+ if (raw is! List) return const [];
+ return raw
+ .whereType